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.
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/PKG-INFO +1 -1
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/__init__.py +1 -1
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_api_types.py +27 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_changes.py +1 -2
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_commands.py +194 -16
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_deploy.py +186 -8
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_devtool_hooks.py +1 -1
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_devtool_publish_rpc.py +1 -1
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_oauth.py +8 -1
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_parser.py +11 -1
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cli.py +8 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/pyproject.toml +1 -1
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/.gitignore +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/LICENSE +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/README.md +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_cloud_env.py +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_definitions.py +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/_run_sync.py +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cloud-env.toml +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cloud_devtools.css +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/cloud_devtools.js +0 -0
- {plotly_cloud-0.2.1 → plotly_cloud-0.4.0}/plotly_cloud/exceptions.py +0 -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
|
-
|
|
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"),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
return
|
|
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
|
|
@@ -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(
|
|
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
|
|
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",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|