plotly-cloud 0.3.0__tar.gz → 0.4.1__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.1}/PKG-INFO +1 -1
  2. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/__init__.py +1 -1
  3. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_api_types.py +27 -0
  4. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_commands.py +179 -5
  5. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_deploy.py +167 -6
  6. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/pyproject.toml +1 -1
  7. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/.gitignore +0 -0
  8. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/LICENSE +0 -0
  9. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/README.md +0 -0
  10. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_changes.py +0 -0
  11. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_cloud_env.py +0 -0
  12. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_definitions.py +0 -0
  13. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_devtool_hooks.py +0 -0
  14. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_devtool_publish_rpc.py +0 -0
  15. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_oauth.py +0 -0
  16. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_parser.py +0 -0
  17. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/_run_sync.py +0 -0
  18. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/cli.py +0 -0
  19. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/cloud-env.toml +0 -0
  20. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/cloud_devtools.css +0 -0
  21. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/plotly_cloud/cloud_devtools.js +0 -0
  22. {plotly_cloud-0.3.0 → plotly_cloud-0.4.1}/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.1
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.1"
@@ -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,
@@ -448,7 +449,7 @@ class PublishCommand(BaseCommand):
448
449
  },
449
450
  {
450
451
  "name": "--team",
451
- "help": "Team name or slug to assign the app to (alternative to --team-id)",
452
+ "help": "Team name to assign the app to (alternative to --team-id)",
452
453
  },
453
454
  {
454
455
  "name": "--output",
@@ -641,7 +642,7 @@ class PublishCommand(BaseCommand):
641
642
  team_name_or_slug = getattr(args, "team", None)
642
643
 
643
644
  if team_name_or_slug and not team_id:
644
- # Need to resolve team name/slug to team_id
645
+ # Need to resolve team name to team_id
645
646
  team_resolve_task = progress.add_task(f"Resolving team '{team_name_or_slug}'...", total=None)
646
647
  try:
647
648
  teams = await deploy_client.list_teams()
@@ -649,7 +650,6 @@ class PublishCommand(BaseCommand):
649
650
  for team in teams:
650
651
  if (
651
652
  team.get("name", "").lower() == team_name_or_slug.lower()
652
- or team.get("team_slug", "").lower() == team_name_or_slug.lower()
653
653
  or team.get("id", "").lower() == team_name_or_slug.lower()
654
654
  ):
655
655
  matching_team = team
@@ -787,6 +787,94 @@ class PublishCommand(BaseCommand):
787
787
  console.print(f"Your app is available at: {format_app_url(app_url)}")
788
788
 
789
789
 
790
+ class ListCommand(BaseCommand):
791
+ """List all apps for the authenticated user."""
792
+
793
+ name = "list"
794
+ group = "app"
795
+ short_description = "📋 List all your published apps"
796
+ description = "Display all applications published to Plotly Cloud."
797
+ arguments: List[CommandArgument] = [
798
+ {
799
+ "name": "--team",
800
+ "help": "Filter apps by team name",
801
+ },
802
+ {
803
+ "name": "--team-id",
804
+ "help": "Filter apps by team ID",
805
+ },
806
+ ]
807
+
808
+ @classmethod
809
+ async def execute(cls, args: ParsedArguments) -> None:
810
+ """Execute list command."""
811
+ client_id = cloud_config.get_oauth_client_id()
812
+ oauth_client = OAuthClient(client_id, token=getattr(args, "api_key", None))
813
+
814
+ if not await oauth_client.is_authenticated():
815
+ raise ApplicationError("Not authenticated. Please run 'plotly login' first.")
816
+
817
+ auth_token = await oauth_client.get_access_token()
818
+ if not auth_token:
819
+ raise ApplicationError("Unable to retrieve access token. Please try logging in again.")
820
+
821
+ async with DeploymentClient(oauth_client) as deploy_client:
822
+ # Resolve team name to team_id if needed
823
+ team_id = getattr(args, "team_id", None)
824
+ team_name_or_slug = getattr(args, "team", None)
825
+
826
+ if team_name_or_slug and not team_id:
827
+ teams = await deploy_client.list_teams()
828
+ for team in teams:
829
+ if (
830
+ team.get("name", "").lower() == team_name_or_slug.lower()
831
+ or team.get("id", "").lower() == team_name_or_slug.lower()
832
+ ):
833
+ team_id = team.get("id")
834
+ break
835
+ if not team_id:
836
+ raise ApplicationError(
837
+ f"Team '{team_name_or_slug}' not found. Use 'plotly user teams' to list available teams."
838
+ )
839
+
840
+ apps = await deploy_client.list_apps(team_id)
841
+
842
+ if not apps:
843
+ console.print(Panel("No apps found.", title="📋 Apps", border_style="yellow"))
844
+ return
845
+
846
+ table = Table(show_header=True, header_style="bold blue", expand=True)
847
+ table.add_column("Name", style="cyan", no_wrap=True, ratio=3)
848
+ table.add_column("Status", style="white", no_wrap=True, ratio=1)
849
+ table.add_column("App ID", style="dim", no_wrap=True, ratio=1)
850
+ table.add_column("Published", style="magenta", no_wrap=True, ratio=1)
851
+
852
+ for app in apps:
853
+ status = app.get("status", "—")
854
+ status_info = REVISION_STATUS_MAP.get(status, {"label": status, "emoji": "?", "color": "white"})
855
+ status_display = (
856
+ f"{status_info['emoji']} [{status_info['color']}]{status_info['label']}[/{status_info['color']}]"
857
+ )
858
+
859
+ last_published = app.get("last_published", "—")
860
+ if last_published and last_published != "—":
861
+ last_published = last_published[:10]
862
+
863
+ app_id = app.get("id", "—")
864
+ app_id_short = app_id[:8] if app_id != "—" else "—"
865
+
866
+ table.add_row(
867
+ app.get("name", "—"),
868
+ status_display,
869
+ app_id_short,
870
+ last_published,
871
+ )
872
+
873
+ console.print()
874
+ console.print(Panel.fit(table, title="📋 Your Apps", border_style="bold blue"))
875
+ console.print()
876
+
877
+
790
878
  class StatusCommand(BaseCommand):
791
879
  """Get the status of an app published to Plotly Cloud."""
792
880
 
@@ -909,7 +997,6 @@ class TeamsCommand(BaseCommand):
909
997
  # Create a table for the teams
910
998
  table = Table(show_header=True, header_style="bold blue")
911
999
  table.add_column("Name", style="cyan", no_wrap=True)
912
- table.add_column("Slug", style="green")
913
1000
  table.add_column("Team ID", style="white", overflow="fold")
914
1001
  table.add_column("Created", style="magenta")
915
1002
 
@@ -917,7 +1004,6 @@ class TeamsCommand(BaseCommand):
917
1004
  for team in teams:
918
1005
  table.add_row(
919
1006
  team.get("name", "—"),
920
- team.get("team_slug", "—"),
921
1007
  team.get("id", "—"),
922
1008
  team.get("created_at", "—")[:10] if team.get("created_at") else "—", # Show only date
923
1009
  )
@@ -932,6 +1018,94 @@ class TeamsCommand(BaseCommand):
932
1018
  console.print()
933
1019
 
934
1020
 
1021
+ class LogsCommand(BaseCommand):
1022
+ """Get build or runtime logs for a published app."""
1023
+
1024
+ name = "logs"
1025
+ group = "app"
1026
+ short_description = "📋 Get logs for a published app"
1027
+ description = "Retrieve build or runtime logs for your published application."
1028
+ arguments: List[CommandArgument] = [
1029
+ {
1030
+ "name": "--project-path",
1031
+ "default": ".",
1032
+ "help": "Path to project directory",
1033
+ },
1034
+ {
1035
+ "name": "--config",
1036
+ "default": "plotly-cloud.toml",
1037
+ "help": "Path to configuration file",
1038
+ },
1039
+ {
1040
+ "name": "--type",
1041
+ "default": "build",
1042
+ "choices": ["build", "runtime"],
1043
+ "help": "Type of logs to retrieve (default: build)",
1044
+ },
1045
+ {
1046
+ "name": "--revision",
1047
+ "help": "Revision ID (default: latest revision)",
1048
+ },
1049
+ ]
1050
+
1051
+ @classmethod
1052
+ async def execute(cls, args: ParsedArguments) -> None:
1053
+ """Execute logs command."""
1054
+ project_path = os.path.abspath(args.project_path)
1055
+ config_path = get_config_path(project_path, args.config)
1056
+ config = load_deployment_config(config_path)
1057
+
1058
+ app_id = config.get("app_id")
1059
+ if not app_id:
1060
+ raise ApplicationError("No app_id found in configuration. Publish your app first using 'plotly publish'.")
1061
+
1062
+ client_id = cloud_config.get_oauth_client_id()
1063
+ oauth_client = OAuthClient(client_id, token=getattr(args, "api_key", None))
1064
+
1065
+ if not await oauth_client.is_authenticated():
1066
+ raise AuthenticationError("Not authenticated. Please run 'plotly login' first.")
1067
+
1068
+ auth_token = await oauth_client.get_access_token()
1069
+ if not auth_token:
1070
+ raise AuthenticationError("Unable to retrieve access token. Please try logging in again.")
1071
+
1072
+ log_type = getattr(args, "type", "build")
1073
+ revision_id = getattr(args, "revision", None)
1074
+
1075
+ async with DeploymentClient(oauth_client) as deploy_client:
1076
+ with Progress(
1077
+ SpinnerColumn(),
1078
+ TextColumn("[progress.description]{task.description}"),
1079
+ console=console,
1080
+ ) as progress:
1081
+ # Resolve revision ID if not provided
1082
+ if not revision_id:
1083
+ task = progress.add_task("Fetching latest revision...", total=None)
1084
+ revisions = await deploy_client.get_app_revisions(app_id)
1085
+ if not revisions:
1086
+ raise ApplicationError("No revisions found for this app.")
1087
+ revision_id = revisions[0]["id"]
1088
+ progress.update(task, description=f"✓ Using revision {revision_id[:8]}...")
1089
+
1090
+ # Fetch logs
1091
+ task = progress.add_task(f"Fetching {log_type} logs...", total=None)
1092
+ log_text, _ = await deploy_client.get_logs(app_id, revision_id, log_type)
1093
+ progress.remove_task(task)
1094
+
1095
+ if not log_text.strip():
1096
+ console.print(f"No {log_type} logs available.")
1097
+ return
1098
+
1099
+ console.print(
1100
+ Panel(
1101
+ log_text,
1102
+ title=f"📋 {log_type.title()} Logs",
1103
+ border_style="blue",
1104
+ expand=True,
1105
+ )
1106
+ )
1107
+
1108
+
935
1109
  class WhoamiCommand(BaseCommand):
936
1110
  """Show current user information."""
937
1111
 
@@ -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()["teams"]
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.1"
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