plotly-cloud 0.2.1__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.2.1 → plotly_cloud-0.4.0}/PKG-INFO +1 -1
  2. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/__init__.py +1 -1
  3. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_api_types.py +27 -0
  4. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_changes.py +1 -2
  5. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_commands.py +194 -16
  6. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_deploy.py +186 -8
  7. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_devtool_hooks.py +1 -1
  8. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_devtool_publish_rpc.py +1 -1
  9. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_oauth.py +8 -1
  10. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_parser.py +11 -1
  11. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cli.py +8 -0
  12. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/pyproject.toml +1 -1
  13. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/.gitignore +0 -0
  14. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/LICENSE +0 -0
  15. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/README.md +0 -0
  16. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_cloud_env.py +0 -0
  17. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_definitions.py +0 -0
  18. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_run_sync.py +0 -0
  19. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cloud-env.toml +0 -0
  20. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cloud_devtools.css +0 -0
  21. {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cloud_devtools.js +0 -0
  22. {plotly_cloud-0.2.1 → 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.2.1
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.2.1"
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
@@ -7,8 +7,7 @@ from concurrent.futures import ThreadPoolExecutor
7
7
  _builtins_modules = list(sys.builtin_module_names) + ["frozen", "builtin"]
8
8
 
9
9
 
10
- if hasattr(sys, "stdlib_module_names"):
11
- _builtins_modules += list(sys.stdlib_module_names) # type: ignore
10
+ _builtins_modules += list(getattr(sys, "stdlib_module_names", []))
12
11
 
13
12
 
14
13
  def collect_module_files(*extras):
@@ -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,
@@ -461,7 +462,7 @@ class PublishCommand(BaseCommand):
461
462
  },
462
463
  {
463
464
  "name": "--poll-status",
464
- "type": lambda x: x.lower() in ("true", "1", "yes", "on"), # type: ignore
465
+ "type": lambda x: x.lower() in ("true", "1", "yes", "on"),
465
466
  "default": True,
466
467
  "help": "Poll publishing status until completion (default: True)",
467
468
  },
@@ -477,6 +478,11 @@ class PublishCommand(BaseCommand):
477
478
  "default": 180,
478
479
  "help": "Polling timeout in seconds",
479
480
  },
481
+ {
482
+ "name": "--skip-size-check",
483
+ "action": "store_true",
484
+ "help": "Skip the standard 200MB project size validation (note: a hard 700MB maximum still applies)",
485
+ },
480
486
  ]
481
487
 
482
488
  @classmethod
@@ -584,7 +590,7 @@ class PublishCommand(BaseCommand):
584
590
  auth_task = progress.add_task("🔐 Checking authentication...", total=None)
585
591
 
586
592
  client_id = cloud_config.get_oauth_client_id()
587
- oauth_client = OAuthClient(client_id)
593
+ oauth_client = OAuthClient(client_id, token=getattr(args, "api_key", None))
588
594
 
589
595
  # Check if authenticated, if not, perform login
590
596
  if not await oauth_client.is_authenticated():
@@ -666,7 +672,8 @@ class PublishCommand(BaseCommand):
666
672
  try:
667
673
  # Create deployment zip
668
674
  package_task = progress.add_task("Creating deployment package...", total=None)
669
- zip_size = await create_deployment_zip(project_path, zip_path)
675
+ skip_size_check = getattr(args, "skip_size_check", False)
676
+ zip_size = await create_deployment_zip(project_path, zip_path, skip_size_check)
670
677
  progress.update(
671
678
  package_task, description=f"✓ Created deployment package: {zip_size / (1024 * 1024):.1f}MB"
672
679
  )
@@ -781,6 +788,95 @@ class PublishCommand(BaseCommand):
781
788
  console.print(f"Your app is available at: {format_app_url(app_url)}")
782
789
 
783
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
+
784
880
  class StatusCommand(BaseCommand):
785
881
  """Get the status of an app published to Plotly Cloud."""
786
882
 
@@ -816,15 +912,12 @@ class StatusCommand(BaseCommand):
816
912
  if not app_id:
817
913
  raise ApplicationError("No app_id found in configuration. Publish your app first using 'plotly publish'.")
818
914
 
819
- # Get OAuth client for authentication
820
915
  client_id = cloud_config.get_oauth_client_id()
821
- oauth_client = OAuthClient(client_id)
916
+ oauth_client = OAuthClient(client_id, token=getattr(args, "api_key", None))
822
917
 
823
- # Check if authenticated
824
918
  if not await oauth_client.is_authenticated():
825
919
  raise ApplicationError("Not authenticated. Please run 'plotly login' first.")
826
920
 
827
- # Get access token
828
921
  auth_token = await oauth_client.get_access_token()
829
922
  if not auth_token:
830
923
  raise ApplicationError("Unable to retrieve access token. Please try logging in again.")
@@ -885,15 +978,12 @@ class TeamsCommand(BaseCommand):
885
978
  @classmethod
886
979
  async def execute(cls, args: ParsedArguments) -> None:
887
980
  """Execute teams command."""
888
- # Get OAuth client for authentication
889
981
  client_id = cloud_config.get_oauth_client_id()
890
- oauth_client = OAuthClient(client_id)
982
+ oauth_client = OAuthClient(client_id, token=getattr(args, "api_key", None))
891
983
 
892
- # Check if authenticated
893
984
  if not await oauth_client.is_authenticated():
894
985
  raise ApplicationError("Not authenticated. Please run 'plotly login' first.")
895
986
 
896
- # Get access token
897
987
  auth_token = await oauth_client.get_access_token()
898
988
  if not auth_token:
899
989
  raise ApplicationError("Unable to retrieve access token. Please try logging in again.")
@@ -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
 
@@ -944,25 +1122,26 @@ class WhoamiCommand(BaseCommand):
944
1122
  @classmethod
945
1123
  async def execute(cls, args: ParsedArguments) -> None:
946
1124
  """Execute the whoami command."""
1125
+ token = getattr(args, "api_key", None)
1126
+ if token:
1127
+ console.print("✓ Authenticated via bearer token")
1128
+ return
1129
+
947
1130
  client_id = cloud_config.get_oauth_client_id()
948
1131
  oauth_client = OAuthClient(client_id)
949
1132
 
950
- # Check if authenticated
951
1133
  if not await oauth_client.is_authenticated():
952
1134
  console.print("✗ Not logged in")
953
1135
  return
954
1136
 
955
- # Load credentials to get user info
956
1137
  credentials = await oauth_client.load_credentials()
957
1138
  if not credentials:
958
1139
  console.print("✗ No credentials found")
959
1140
  return
960
1141
 
961
- # Try to refresh token to validate it
962
1142
  try:
963
1143
  await oauth_client.refresh_access_token()
964
1144
 
965
- # Extract user information from credentials
966
1145
  user_info = credentials.get("user", {})
967
1146
  email = user_info.get("email") or credentials.get("email")
968
1147
 
@@ -972,6 +1151,5 @@ class WhoamiCommand(BaseCommand):
972
1151
  console.print("✓ Logged in (no email information available)")
973
1152
 
974
1153
  except (TokenError, CredentialError):
975
- # Token is invalid and cannot be refreshed, clear credentials
976
1154
  await oauth_client.logout()
977
1155
  console.print("✗ Invalid token - credentials cleared")
@@ -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
@@ -30,8 +30,9 @@ from .exceptions import (
30
30
  PlotlyCloudError,
31
31
  )
32
32
 
33
- # Maximum allowed zip file size (200MB)
33
+ # Maximum allowed zip file size (200MB default, 700MB absolute max)
34
34
  MAX_ZIP_SIZE = 200 * 1024 * 1024
35
+ ABSOLUTE_MAX_ZIP_SIZE = 700 * 1024 * 1024
35
36
 
36
37
 
37
38
  def parse_gitignore(project_path: str) -> set[str]:
@@ -111,19 +112,21 @@ def should_exclude_path(path: str, exclude_patterns: set[str]) -> bool:
111
112
  return False
112
113
 
113
114
 
114
- async def create_deployment_zip(project_path: str, output_path: str) -> int:
115
+ async def create_deployment_zip(project_path: str, output_path: str, skip_size_check: bool = False) -> int:
115
116
  """
116
117
  Create a zip file for deployment, excluding files based on .gitignore.
117
118
 
118
119
  Args:
119
120
  project_path: Path to the project directory
120
121
  output_path: Path where the zip file should be created
122
+ skip_size_check: If True, skip the maximum size validation
121
123
 
122
124
  Returns:
123
125
  Size of the created zip file in bytes
124
126
 
125
127
  Raises:
126
- ValueError: If zip file exceeds size limit
128
+ FileSizeError: If zip file exceeds size limit and skip_size_check is False
129
+ FileSystemError: If the .gitignore file cannot be read
127
130
  """
128
131
  exclude_patterns = parse_gitignore(project_path)
129
132
  total_uncompressed_size = 0
@@ -165,7 +168,18 @@ async def create_deployment_zip(project_path: str, output_path: str) -> int:
165
168
  # Check uncompressed size only
166
169
  zip_size = os.path.getsize(output_path)
167
170
 
168
- if total_uncompressed_size > MAX_ZIP_SIZE:
171
+ # Always enforce absolute maximum (700MB)
172
+ if total_uncompressed_size > ABSOLUTE_MAX_ZIP_SIZE:
173
+ os.remove(output_path) # Clean up the oversized zip
174
+ raise FileSizeError(
175
+ f"This directory exceeds {ABSOLUTE_MAX_ZIP_SIZE / (1024 * 1024):.0f}MB and couldn't be published",
176
+ f"Total size: {total_uncompressed_size / (1024 * 1024):.1f}MB. "
177
+ f"Maximum allowed: {ABSOLUTE_MAX_ZIP_SIZE / (1024 * 1024):.0f}MB. "
178
+ "Consider excluding large files in your .gitignore.",
179
+ )
180
+
181
+ # Check default limit (200MB) unless skipped
182
+ if not skip_size_check and total_uncompressed_size > MAX_ZIP_SIZE:
169
183
  os.remove(output_path) # Clean up the oversized zip
170
184
  raise FileSizeError(
171
185
  f"This directory exceeds {MAX_ZIP_SIZE / (1024 * 1024):.0f}MB and couldn't be published",
@@ -247,7 +261,8 @@ def _handle_error_response(response: Optional[httpx.Response], operation: str) -
247
261
 
248
262
  # Handle 403 Forbidden specifically
249
263
  if response.status_code == 403:
250
- return ForbiddenError(f"Failed to {operation}: Access forbidden")
264
+ details = response.text.strip()[:200] if response.text else ""
265
+ return ForbiddenError(f"Failed to {operation}: Access forbidden", details=details)
251
266
 
252
267
  # Parse error based on content type
253
268
  content_type = response.headers.get("content-type", "").lower()
@@ -495,8 +510,8 @@ class DeploymentClient:
495
510
  response = await self._client.get(url)
496
511
 
497
512
  if response.status_code == 200:
498
- teams: list[Team] = response.json()
499
- return teams
513
+ memberships: list[UserTeamMembership] = response.json()
514
+ return [m["team"] for m in memberships]
500
515
 
501
516
  token_refreshed = await self._refresh_token_if_needed(response)
502
517
  if token_refreshed:
@@ -507,3 +522,166 @@ class DeploymentClient:
507
522
  raise _handle_error_response(response, "list teams")
508
523
  except httpx.RequestError as e:
509
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
@@ -10,7 +10,7 @@ def install_hook():
10
10
 
11
11
  try:
12
12
  # The style only works with the position left defined.
13
- dash.hooks.devtool( # type: ignore
13
+ dash.hooks.devtool(
14
14
  "plotly_cloud_publish_component",
15
15
  "PlotlyCloudPublishComponent",
16
16
  {"id": "_plotly-cloud-publish"},
@@ -226,7 +226,7 @@ class PlotlyCloudPublishRPC:
226
226
  device_code = data.get("device_code")
227
227
  status_code, response = await self.oauth_client.check_authentication_status(device_code)
228
228
  if status_code == 200:
229
- await self.oauth_client._save_credentials(dict(response))
229
+ await self.oauth_client._save_credentials({**response})
230
230
  return {"result": {"success": True}}
231
231
  else:
232
232
  error = response.get("error", "unknown_error")
@@ -59,8 +59,9 @@ AuthResponse = Union[AuthTokenResponse, AuthErrorResponse]
59
59
  class OAuthClient:
60
60
  """OAuth client for WorkOS CLI Auth using device authorization flow."""
61
61
 
62
- def __init__(self, client_id: str):
62
+ def __init__(self, client_id: str, token: Optional[str] = None):
63
63
  self.client_id = client_id
64
+ self.token = token
64
65
  self.credentials_path = self._get_credentials_path()
65
66
 
66
67
  def _get_credentials_path(self) -> Path:
@@ -278,11 +279,15 @@ class OAuthClient:
278
279
 
279
280
  async def is_authenticated(self) -> bool:
280
281
  """Check if user is authenticated."""
282
+ if self.token:
283
+ return True
281
284
  credentials = await self.load_credentials()
282
285
  return credentials is not None and "access_token" in credentials
283
286
 
284
287
  async def get_access_token(self) -> Optional[str]:
285
288
  """Get current access token."""
289
+ if self.token:
290
+ return self.token
286
291
  credentials = await self.load_credentials()
287
292
  if credentials:
288
293
  return credentials.get("access_token")
@@ -294,6 +299,8 @@ class OAuthClient:
294
299
  Raises:
295
300
  TokenError: If no refresh token available or refresh fails
296
301
  """
302
+ if self.token:
303
+ raise TokenError("Cannot refresh a static API key")
297
304
  credentials = await self.load_credentials()
298
305
  if not credentials or "refresh_token" not in credentials:
299
306
  raise TokenError("No refresh token available")
@@ -1,5 +1,6 @@
1
1
  """Simple argument parser to replace argparse."""
2
2
 
3
+ import os
3
4
  import sys
4
5
  from typing import Any, List
5
6
 
@@ -78,9 +79,10 @@ def parse_args(command_arguments: List[CommandArgument], args_index=3) -> Parsed
78
79
 
79
80
  result[key] = arg_spec.get("default")
80
81
 
81
- # Add global verbose flag and help flag
82
+ # Add global flags
82
83
  result["verbose"] = False
83
84
  result["help"] = False
85
+ result["api_key"] = os.getenv("PLOTLY_API_KEY", None)
84
86
 
85
87
  # Check if the first argument is "help" and handle it specially
86
88
  if len(args) > 0 and args[0] == "help":
@@ -103,6 +105,14 @@ def parse_args(command_arguments: List[CommandArgument], args_index=3) -> Parsed
103
105
  i += 1
104
106
  continue
105
107
 
108
+ if arg == "--api-key":
109
+ if i + 1 < len(args):
110
+ result["api_key"] = args[i + 1]
111
+ i += 2
112
+ else:
113
+ i += 1
114
+ continue
115
+
106
116
  # Check if this is an optional argument
107
117
  if arg.startswith("-"):
108
118
  # Find matching optional argument specification
@@ -110,6 +110,10 @@ def print_main_help(show_banner=True) -> None:
110
110
 
111
111
  # Show global options
112
112
  global_options = [
113
+ {
114
+ "name": "--api-key",
115
+ "help": "API key for authentication (overrides OAuth, or set PLOTLY_API_KEY env var)",
116
+ },
113
117
  {
114
118
  "name": "--verbose, -v",
115
119
  "help": "Enable verbose output with detailed error information",
@@ -196,6 +200,10 @@ def print_command_help(command_class: BaseCommand, group: str, command: str) ->
196
200
 
197
201
  # Show global options
198
202
  global_options = [
203
+ {
204
+ "name": "--api-key",
205
+ "help": "API key for authentication (overrides OAuth, or set PLOTLY_API_KEY env var)",
206
+ },
199
207
  {
200
208
  "name": "--verbose, -v",
201
209
  "help": "Enable verbose output with detailed error information",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plotly-cloud"
7
- version = "0.2.1"
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