mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
Add Google Drive cloud storage uploads (#6884)
Co-authored-by: Suchintan Singh <suchintan@skyvern.com>
This commit is contained in:
parent
c2802b29d3
commit
a293dbfae5
38 changed files with 956 additions and 23 deletions
|
|
@ -9999,6 +9999,12 @@
|
|||
],
|
||||
"title": "Proxy Session Id",
|
||||
"description": "Optional advanced reuse key for this credential's pinned proxy identity."
|
||||
},
|
||||
"rotate_proxy_session_id": {
|
||||
"type": "boolean",
|
||||
"title": "Rotate Proxy Session Id",
|
||||
"description": "Rotate the Skyvern-managed proxy sticky-session id when updating this credential.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
@ -12276,7 +12282,8 @@
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"s3",
|
||||
"azure"
|
||||
"azure",
|
||||
"google_drive"
|
||||
],
|
||||
"title": "FileStorageType"
|
||||
},
|
||||
|
|
@ -12433,6 +12440,28 @@
|
|||
],
|
||||
"title": "Azure Blob Container Name"
|
||||
},
|
||||
"google_credential_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Credential Id"
|
||||
},
|
||||
"google_drive_folder_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Drive Folder Id"
|
||||
},
|
||||
"path": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -12602,6 +12631,28 @@
|
|||
],
|
||||
"title": "Azure Folder Path"
|
||||
},
|
||||
"google_credential_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Credential Id"
|
||||
},
|
||||
"google_drive_folder_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Drive Folder Id"
|
||||
},
|
||||
"path": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -21581,6 +21632,12 @@
|
|||
],
|
||||
"title": "Proxy Session Id",
|
||||
"description": "Opaque Skyvern-managed proxy sticky-session id."
|
||||
},
|
||||
"rotate_proxy_session_id": {
|
||||
"type": "boolean",
|
||||
"title": "Rotate Proxy Session Id",
|
||||
"description": "Rotate the Skyvern-managed proxy sticky-session id for this profile.",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -11517,7 +11517,8 @@
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"s3",
|
||||
"azure"
|
||||
"azure",
|
||||
"google_drive"
|
||||
],
|
||||
"title": "FileStorageType"
|
||||
},
|
||||
|
|
@ -11674,6 +11675,28 @@
|
|||
],
|
||||
"title": "Azure Blob Container Name"
|
||||
},
|
||||
"google_credential_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Credential Id"
|
||||
},
|
||||
"google_drive_folder_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Drive Folder Id"
|
||||
},
|
||||
"path": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -11837,6 +11860,28 @@
|
|||
],
|
||||
"title": "Azure Folder Path"
|
||||
},
|
||||
"google_credential_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Credential Id"
|
||||
},
|
||||
"google_drive_folder_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Google Drive Folder Id"
|
||||
},
|
||||
"path": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||
import {
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
getDefaultGoogleOAuthCredentialId,
|
||||
hasGoogleOAuthCredentialScopes,
|
||||
useGoogleOAuthCredentials,
|
||||
|
|
@ -22,6 +21,7 @@ type Props = {
|
|||
nodeId: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
requiredScopes: readonly string[];
|
||||
};
|
||||
|
||||
const ADVANCED_OPTION = "__advanced__";
|
||||
|
|
@ -31,6 +31,7 @@ function GoogleOAuthCredentialSelector({
|
|||
nodeId,
|
||||
value,
|
||||
onChange,
|
||||
requiredScopes,
|
||||
}: Readonly<Props>) {
|
||||
const {
|
||||
credentials: allCredentials,
|
||||
|
|
@ -39,7 +40,7 @@ function GoogleOAuthCredentialSelector({
|
|||
} = useGoogleOAuthCredentials();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const credentials = allCredentials.filter((credential) =>
|
||||
hasGoogleOAuthCredentialScopes(credential, GOOGLE_SHEETS_REQUIRED_SCOPES),
|
||||
hasGoogleOAuthCredentialScopes(credential, requiredScopes),
|
||||
);
|
||||
|
||||
// Keep latest callback without forcing effect re-runs.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { HelpTooltip } from "@/components/HelpTooltip";
|
|||
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
|
||||
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { GoogleOAuthCredentialSelector } from "@/routes/workflows/components/GoogleOAuthCredentialSelector";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { GOOGLE_DRIVE_REQUIRED_SCOPES } from "@/util/googleScopes";
|
||||
|
||||
import { helpTooltips } from "../../helpContent";
|
||||
import { type FileUploadNode, type FileUploadNodeData } from "./types";
|
||||
|
|
@ -45,6 +47,8 @@ function FileUploadEditorBody({
|
|||
azureStorageAccountName,
|
||||
azureStorageAccountKey,
|
||||
azureBlobContainerName,
|
||||
googleCredentialId,
|
||||
googleDriveFolderId,
|
||||
} = data;
|
||||
const update = useUpdate<FileUploadNodeData>({ id: blockId, editable });
|
||||
|
||||
|
|
@ -58,7 +62,8 @@ function FileUploadEditorBody({
|
|||
<Select
|
||||
value={storageType}
|
||||
onValueChange={(value) =>
|
||||
value && update({ storageType: value as "s3" | "azure" })
|
||||
value &&
|
||||
update({ storageType: value as "s3" | "azure" | "google_drive" })
|
||||
}
|
||||
disabled={!editable}
|
||||
>
|
||||
|
|
@ -68,6 +73,7 @@ function FileUploadEditorBody({
|
|||
<SelectContent>
|
||||
<SelectItem value="s3">Amazon S3</SelectItem>
|
||||
<SelectItem value="azure">Azure Blob Storage</SelectItem>
|
||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -223,6 +229,37 @@ function FileUploadEditorBody({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{storageType === "google_drive" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm text-slate-400">Google Account</Label>
|
||||
<HelpTooltip content="The connected Google account used for Drive uploads." />
|
||||
</div>
|
||||
<GoogleOAuthCredentialSelector
|
||||
nodeId={blockId}
|
||||
value={googleCredentialId ?? ""}
|
||||
onChange={(value) => update({ googleCredentialId: value })}
|
||||
requiredScopes={GOOGLE_DRIVE_REQUIRED_SCOPES}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm text-slate-400">
|
||||
Google Drive Folder ID (Required)
|
||||
</Label>
|
||||
<HelpTooltip content="Required destination Google Drive folder ID. You can paste a Drive folder URL or a bare folder ID." />
|
||||
</div>
|
||||
<WorkflowBlockInputTextarea
|
||||
nodeId={blockId}
|
||||
onChange={(value) => update({ googleDriveFolderId: value })}
|
||||
value={googleDriveFolderId ?? ""}
|
||||
className="nopan text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowT
|
|||
export type FileUploadNodeData = NodeBaseData & {
|
||||
path: string;
|
||||
editable: boolean;
|
||||
storageType: "s3" | "azure";
|
||||
storageType: "s3" | "azure" | "google_drive";
|
||||
s3Bucket: string | null;
|
||||
awsAccessKeyId: string | null;
|
||||
awsSecretAccessKey: string | null;
|
||||
|
|
@ -13,6 +13,8 @@ export type FileUploadNodeData = NodeBaseData & {
|
|||
azureStorageAccountName: string | null;
|
||||
azureStorageAccountKey: string | null;
|
||||
azureBlobContainerName: string | null;
|
||||
googleCredentialId: string | null;
|
||||
googleDriveFolderId: string | null;
|
||||
};
|
||||
|
||||
export type FileUploadNode = Node<FileUploadNodeData, "fileUpload">;
|
||||
|
|
@ -30,6 +32,8 @@ export const fileUploadNodeDefaultData: FileUploadNodeData = {
|
|||
azureStorageAccountName: null,
|
||||
azureStorageAccountKey: null,
|
||||
azureBlobContainerName: null,
|
||||
googleCredentialId: null,
|
||||
googleDriveFolderId: null,
|
||||
continueOnFailure: false,
|
||||
model: null,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useGoogleOAuthCredentials } from "@/hooks/useGoogleOAuthCredentials";
|
||||
import {
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
useGoogleOAuthCredentials,
|
||||
} from "@/hooks/useGoogleOAuthCredentials";
|
||||
import { useGoogleSpreadsheet } from "@/hooks/useGoogleSpreadsheet";
|
||||
import { GoogleOAuthCredentialSelector } from "@/routes/workflows/components/GoogleOAuthCredentialSelector";
|
||||
import { SheetTabCombobox } from "@/routes/workflows/components/SheetTabCombobox";
|
||||
|
|
@ -102,6 +105,7 @@ function GoogleSheetsReadEditorBody({
|
|||
nodeId={blockId}
|
||||
value={data.credentialId}
|
||||
onChange={(next) => update({ credentialId: next })}
|
||||
requiredScopes={GOOGLE_SHEETS_REQUIRED_SCOPES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useGoogleOAuthCredentials } from "@/hooks/useGoogleOAuthCredentials";
|
||||
import {
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES,
|
||||
useGoogleOAuthCredentials,
|
||||
} from "@/hooks/useGoogleOAuthCredentials";
|
||||
import { useGoogleSheetDimensions } from "@/hooks/useGoogleSheetDimensions";
|
||||
import { useGoogleSheetHeaders } from "@/hooks/useGoogleSheetHeaders";
|
||||
import { useGoogleSpreadsheet } from "@/hooks/useGoogleSpreadsheet";
|
||||
|
|
@ -144,6 +147,7 @@ function GoogleSheetsWriteEditorBody({
|
|||
nodeId={blockId}
|
||||
value={data.credentialId}
|
||||
onChange={(next) => update({ credentialId: next })}
|
||||
requiredScopes={GOOGLE_SHEETS_REQUIRED_SCOPES}
|
||||
/>
|
||||
{needsReconnect ? (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -62,6 +62,19 @@ vi.mock("@/components/WorkflowBlockInputTextarea", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/routes/workflows/components/GoogleOAuthCredentialSelector", () => ({
|
||||
GoogleOAuthCredentialSelector: (props: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => (
|
||||
<input
|
||||
data-testid="google-oauth-selector"
|
||||
value={props.value ?? ""}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub the shadcn Select to a native <select> so we can fire change events
|
||||
// directly. The form only consumes value + onValueChange.
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
|
|
@ -123,7 +136,7 @@ afterEach(() => {
|
|||
function setFileUploadNode(
|
||||
id: string,
|
||||
overrides: Partial<{
|
||||
storageType: "s3" | "azure";
|
||||
storageType: "s3" | "azure" | "google_drive";
|
||||
path: string;
|
||||
s3Bucket: string | null;
|
||||
awsAccessKeyId: string | null;
|
||||
|
|
@ -132,6 +145,8 @@ function setFileUploadNode(
|
|||
azureStorageAccountName: string | null;
|
||||
azureStorageAccountKey: string | null;
|
||||
azureBlobContainerName: string | null;
|
||||
googleCredentialId: string | null;
|
||||
googleDriveFolderId: string | null;
|
||||
editable: boolean;
|
||||
}> = {},
|
||||
) {
|
||||
|
|
@ -148,6 +163,8 @@ function setFileUploadNode(
|
|||
azureStorageAccountName: overrides.azureStorageAccountName ?? null,
|
||||
azureStorageAccountKey: overrides.azureStorageAccountKey ?? null,
|
||||
azureBlobContainerName: overrides.azureBlobContainerName ?? null,
|
||||
googleCredentialId: overrides.googleCredentialId ?? null,
|
||||
googleDriveFolderId: overrides.googleDriveFolderId ?? null,
|
||||
editable: overrides.editable ?? true,
|
||||
label: "file_upload_1",
|
||||
continueOnFailure: false,
|
||||
|
|
@ -191,6 +208,8 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
|
|||
expect(screen.queryByText("Storage Account Name")).toBeNull();
|
||||
expect(screen.queryByText("Storage Account Key")).toBeNull();
|
||||
expect(screen.queryByText("Blob Container Name")).toBeNull();
|
||||
expect(screen.queryByText("Google Account")).toBeNull();
|
||||
expect(screen.queryByText("Google Drive Folder ID")).toBeNull();
|
||||
|
||||
// 4 textareas (key id, bucket, region, path) and 1 password input
|
||||
const textareas = screen.getAllByTestId("wbi-textarea");
|
||||
|
|
@ -220,6 +239,8 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
|
|||
expect(screen.queryByText("AWS Secret Access Key")).toBeNull();
|
||||
expect(screen.queryByText("S3 Bucket")).toBeNull();
|
||||
expect(screen.queryByText("Region Name")).toBeNull();
|
||||
expect(screen.queryByText("Google Account")).toBeNull();
|
||||
expect(screen.queryByText("Google Drive Folder ID")).toBeNull();
|
||||
|
||||
// 3 textareas (account name, container, path) and 1 password input
|
||||
const textareas = screen.getAllByTestId("wbi-textarea");
|
||||
|
|
@ -228,6 +249,24 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
|
|||
expect(password.value).toBe("key");
|
||||
});
|
||||
|
||||
test("renders google drive fields when storageType is 'google_drive'", () => {
|
||||
setFileUploadNode("f1", {
|
||||
storageType: "google_drive",
|
||||
googleCredentialId: "goac_123",
|
||||
googleDriveFolderId: "folder_123",
|
||||
});
|
||||
render(<FileUploadBlockForm blockId="f1" />);
|
||||
|
||||
expect(screen.getByText("Google Account")).toBeDefined();
|
||||
expect(screen.getByText("Google Drive Folder ID (Required)")).toBeDefined();
|
||||
expect(
|
||||
(screen.getByTestId("google-oauth-selector") as HTMLInputElement).value,
|
||||
).toBe("goac_123");
|
||||
expect(
|
||||
(screen.getByTestId("wbi-textarea") as HTMLTextAreaElement).value,
|
||||
).toBe("folder_123");
|
||||
});
|
||||
|
||||
test("switching storageType s3 -> azure swaps the conditional fields", () => {
|
||||
setFileUploadNode("f1", { storageType: "s3" });
|
||||
const { rerender } = render(<FileUploadBlockForm blockId="f1" />);
|
||||
|
|
@ -247,11 +286,11 @@ describe("FileUploadBlockForm (SKY-9361)", () => {
|
|||
render(<FileUploadBlockForm blockId="f1" />);
|
||||
|
||||
fireEvent.change(screen.getByTestId("storage-type-select"), {
|
||||
target: { value: "azure" },
|
||||
target: { value: "google_drive" },
|
||||
});
|
||||
|
||||
expect(updateNodeData).toHaveBeenCalledWith("f1", {
|
||||
storageType: "azure",
|
||||
storageType: "google_drive",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ function FileUploadBlockFormBody({
|
|||
azureStorageAccountName,
|
||||
azureStorageAccountKey,
|
||||
azureBlobContainerName,
|
||||
googleCredentialId,
|
||||
googleDriveFolderId,
|
||||
} = node.data;
|
||||
|
||||
const value = useMemo(
|
||||
|
|
@ -49,6 +51,8 @@ function FileUploadBlockFormBody({
|
|||
azureStorageAccountName,
|
||||
azureStorageAccountKey,
|
||||
azureBlobContainerName,
|
||||
googleCredentialId,
|
||||
googleDriveFolderId,
|
||||
}),
|
||||
[
|
||||
storageType,
|
||||
|
|
@ -60,6 +64,8 @@ function FileUploadBlockFormBody({
|
|||
azureStorageAccountName,
|
||||
azureStorageAccountKey,
|
||||
azureBlobContainerName,
|
||||
googleCredentialId,
|
||||
googleDriveFolderId,
|
||||
],
|
||||
);
|
||||
const { commit } = useDebouncedSidebarSave({
|
||||
|
|
|
|||
|
|
@ -161,6 +161,11 @@ vi.mock("@/routes/workflows/components/SheetTabCombobox", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/hooks/useGoogleOAuthCredentials", () => ({
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES: [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/drive.metadata.readonly",
|
||||
],
|
||||
useGoogleOAuthCredentials: () => ({
|
||||
credentials: [{ id: "cred_42", valid: true }],
|
||||
isLoading: false,
|
||||
|
|
|
|||
|
|
@ -179,6 +179,11 @@ vi.mock("@/routes/workflows/components/ColumnMappingEditor", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/hooks/useGoogleOAuthCredentials", () => ({
|
||||
GOOGLE_SHEETS_REQUIRED_SCOPES: [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/drive.metadata.readonly",
|
||||
],
|
||||
useGoogleOAuthCredentials: () => ({
|
||||
credentials: [{ id: "cred_42", valid: true }],
|
||||
isLoading: false,
|
||||
|
|
|
|||
|
|
@ -1061,6 +1061,8 @@ function convertToNode(
|
|||
azureStorageAccountName: block.azure_storage_account_name ?? "",
|
||||
azureStorageAccountKey: block.azure_storage_account_key ?? "",
|
||||
azureBlobContainerName: block.azure_blob_container_name ?? "",
|
||||
googleCredentialId: block.google_credential_id ?? "",
|
||||
googleDriveFolderId: block.google_drive_folder_id ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -3034,6 +3036,8 @@ function getWorkflowBlock(
|
|||
azure_storage_account_name: node.data.azureStorageAccountName ?? "",
|
||||
azure_storage_account_key: node.data.azureStorageAccountKey ?? "",
|
||||
azure_blob_container_name: node.data.azureBlobContainerName ?? "",
|
||||
google_credential_id: node.data.googleCredentialId ?? "",
|
||||
google_drive_folder_id: node.data.googleDriveFolderId ?? "",
|
||||
};
|
||||
}
|
||||
case "fileParser": {
|
||||
|
|
@ -4367,6 +4371,8 @@ function convertBlocksToBlockYAML(
|
|||
azure_storage_account_name: block.azure_storage_account_name ?? "",
|
||||
azure_storage_account_key: block.azure_storage_account_key ?? "",
|
||||
azure_blob_container_name: block.azure_blob_container_name ?? "",
|
||||
google_credential_id: block.google_credential_id ?? "",
|
||||
google_drive_folder_id: block.google_drive_folder_id ?? "",
|
||||
};
|
||||
return blockYaml;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,7 +416,7 @@ export type UploadToS3Block = WorkflowBlockBase & {
|
|||
export type FileUploadBlock = WorkflowBlockBase & {
|
||||
block_type: "file_upload";
|
||||
path: string;
|
||||
storage_type: "s3" | "azure";
|
||||
storage_type: "s3" | "azure" | "google_drive";
|
||||
s3_bucket: string | null;
|
||||
region_name: string | null;
|
||||
aws_access_key_id: string | null;
|
||||
|
|
@ -424,6 +424,8 @@ export type FileUploadBlock = WorkflowBlockBase & {
|
|||
azure_storage_account_name: string | null;
|
||||
azure_storage_account_key: string | null;
|
||||
azure_blob_container_name: string | null;
|
||||
google_credential_id: string | null;
|
||||
google_drive_folder_id: string | null;
|
||||
};
|
||||
|
||||
export type SendEmailBlock = WorkflowBlockBase & {
|
||||
|
|
|
|||
|
|
@ -348,6 +348,8 @@ export type FileUploadBlockYAML = BlockYAMLBase & {
|
|||
azure_storage_account_name?: string | null;
|
||||
azure_storage_account_key?: string | null;
|
||||
azure_blob_container_name?: string | null;
|
||||
google_credential_id?: string | null;
|
||||
google_drive_folder_id?: string | null;
|
||||
};
|
||||
|
||||
export type SendEmailBlockYAML = BlockYAMLBase & {
|
||||
|
|
|
|||
|
|
@ -7,3 +7,7 @@ export const GOOGLE_SHEETS_REQUIRED_SCOPES = [
|
|||
export const GOOGLE_GMAIL_REQUIRED_SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
] as const;
|
||||
|
||||
export const GOOGLE_DRIVE_REQUIRED_SCOPES = [
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
import typing
|
||||
|
||||
FileStorageType = typing.Union[typing.Literal["s3", "azure"], typing.Any]
|
||||
FileStorageType = typing.Union[typing.Literal["s3", "azure", "google_drive"], typing.Any]
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ class FileUploadBlock(UniversalBaseModel):
|
|||
azure_storage_account_name: typing.Optional[str] = None
|
||||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ class FileUploadBlockYaml(UniversalBaseModel):
|
|||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
azure_folder_path: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -257,6 +257,8 @@ class ForLoopBlockLoopBlocksItem_FileUpload(UniversalBaseModel):
|
|||
azure_storage_account_name: typing.Optional[str] = None
|
||||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ class ForLoopBlockYamlLoopBlocksItem_FileUpload(UniversalBaseModel):
|
|||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
azure_folder_path: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -257,6 +257,8 @@ class WhileLoopBlockLoopBlocksItem_FileUpload(UniversalBaseModel):
|
|||
azure_storage_account_name: typing.Optional[str] = None
|
||||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ class WhileLoopBlockYamlLoopBlocksItem_FileUpload(UniversalBaseModel):
|
|||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
azure_folder_path: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -257,6 +257,8 @@ class WorkflowDefinitionBlocksItem_FileUpload(UniversalBaseModel):
|
|||
azure_storage_account_name: typing.Optional[str] = None
|
||||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -189,6 +189,8 @@ class WorkflowDefinitionYamlBlocksItem_FileUpload(UniversalBaseModel):
|
|||
azure_storage_account_key: typing.Optional[str] = None
|
||||
azure_blob_container_name: typing.Optional[str] = None
|
||||
azure_folder_path: typing.Optional[str] = None
|
||||
google_credential_id: typing.Optional[str] = None
|
||||
google_drive_folder_id: typing.Optional[str] = None
|
||||
path: typing.Optional[str] = None
|
||||
|
||||
if IS_PYDANTIC_V2:
|
||||
|
|
|
|||
|
|
@ -676,6 +676,9 @@ class Settings(BaseSettings):
|
|||
# Google Sheets API runtime tuning
|
||||
GOOGLE_SHEETS_API_TIMEOUT_SECONDS: float = 30.0
|
||||
GOOGLE_SHEETS_API_MAX_RETRIES: int = 3
|
||||
# Google Drive API runtime tuning
|
||||
GOOGLE_DRIVE_API_TIMEOUT_SECONDS: float = 30.0
|
||||
GOOGLE_DRIVE_API_MAX_RETRIES: int = 3
|
||||
|
||||
# Cleanup Cron Settings
|
||||
ENABLE_CLEANUP_CRON: bool = False
|
||||
|
|
|
|||
|
|
@ -2130,6 +2130,8 @@ def _build_file_upload_statement(block: dict[str, Any]) -> cst.SimpleStatementLi
|
|||
"azure_storage_account_name",
|
||||
"azure_storage_account_key",
|
||||
"azure_blob_container_name",
|
||||
"google_credential_id",
|
||||
"google_drive_folder_id",
|
||||
"path",
|
||||
]:
|
||||
if block.get(key) is not None:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ from skyvern.forge.sdk.db.agent_db import AgentDB
|
|||
from skyvern.forge.sdk.models import Step, StepStatus
|
||||
from skyvern.forge.sdk.schemas.organizations import Organization
|
||||
from skyvern.forge.sdk.schemas.tasks import Task, TaskStatus
|
||||
from skyvern.forge.sdk.services import google_oauth_service
|
||||
from skyvern.forge.sdk.services import google_drive_service, google_oauth_service
|
||||
from skyvern.forge.sdk.trace import traced
|
||||
from skyvern.forge.sdk.workflow.models.block import BlockTypeVar
|
||||
from skyvern.schemas.workflows import FileStorageType, FileUploadDestination
|
||||
|
|
@ -1411,7 +1411,7 @@ class AgentFunction:
|
|||
organization_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> str:
|
||||
"""Upload a single file to customer-specified S3 or Azure storage.
|
||||
"""Upload a single file to customer-specified cloud storage.
|
||||
|
||||
Returns the customer-facing URI (``destination.customer_uri``). The
|
||||
cloud override routes NAT-org traffic through the egress proxy so it
|
||||
|
|
@ -1447,6 +1447,16 @@ class AgentFunction:
|
|||
await azure_client.upload_file_from_path(destination.sdk_uri, file_path)
|
||||
return destination.customer_uri
|
||||
|
||||
if destination.storage_type == FileStorageType.GOOGLE_DRIVE:
|
||||
if not destination.google_access_token or not destination.google_drive_folder_id:
|
||||
raise ValueError("Google Drive destination is missing required fields")
|
||||
uploaded_file = await google_drive_service.upload_file(
|
||||
access_token=destination.google_access_token,
|
||||
file_path=file_path,
|
||||
folder_id=destination.google_drive_folder_id,
|
||||
)
|
||||
return uploaded_file.web_view_link or f"https://drive.google.com/file/d/{uploaded_file.id}/view"
|
||||
|
||||
raise ValueError(f"Unsupported storage type: {destination.storage_type}")
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@ Purpose: Upload files to storage. This is the "Cloud Storage Block" in the UI.
|
|||
Structure:
|
||||
block_type: file_upload
|
||||
label: <unique_label>
|
||||
storage_type: <s3|azure> # Optional: Storage backend (default: s3)
|
||||
storage_type: <s3|azure|google_drive> # Optional: Storage backend (default: s3)
|
||||
s3_bucket: <bucket_name> # Optional: S3 bucket name
|
||||
aws_access_key_id: <access_key_id> # Optional: AWS access key id
|
||||
aws_secret_access_key: <secret_access_key> # Optional: AWS secret access key
|
||||
|
|
@ -617,11 +617,14 @@ azure_storage_account_name: <account_name> # Optional: Azure storage account
|
|||
azure_storage_account_key: <account_key> # Optional: Azure storage account key
|
||||
azure_blob_container_name: <container_name> # Optional: Azure blob container
|
||||
azure_folder_path: <folder_path> # Optional: Azure folder path
|
||||
google_credential_id: <credential_id> # Required for google_drive: Google OAuth credential ID
|
||||
google_drive_folder_id: <folder_id_or_url> # Required for google_drive: Drive folder ID or URL
|
||||
path: <local_or_workspace_path> # Optional: File path to upload
|
||||
|
||||
Use Cases:
|
||||
- Upload downloaded artifacts to S3
|
||||
- Publish files to Azure Blob Storage
|
||||
- Upload workflow files to a Google Drive folder
|
||||
- Persist workflow outputs to storage
|
||||
|
||||
Example:
|
||||
|
|
@ -634,6 +637,16 @@ blocks:
|
|||
region_name: "us-west-2"
|
||||
path: "/tmp/latest_report.pdf"
|
||||
|
||||
Google Drive example:
|
||||
blocks:
|
||||
- block_type: file_upload
|
||||
label: upload_report_to_drive
|
||||
next_block_label: null
|
||||
storage_type: google_drive
|
||||
google_credential_id: "cred_google_drive"
|
||||
google_drive_folder_id: "https://drive.google.com/drive/folders/folder_id"
|
||||
path: "/tmp/latest_report.pdf"
|
||||
|
||||
** FILE PARSER BLOCK (file_url_parser) **
|
||||
|
||||
Purpose: Parse PDFs, CSVs, Excel files, images, and DOCX files. This is the "File Parser Block" in the UI.
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ class GoogleOAuthCredentialListResponse(BaseModel):
|
|||
class CreateGoogleOAuthAuthorizeRequest(BaseModel):
|
||||
redirect_uri: str = Field(..., description="Redirect URI the consent flow will return to")
|
||||
credential_name: str = Field(default="Default", description="Human-readable name for this credential")
|
||||
scope_profile: Literal["google_sheets", "gmail"] | None = Field(
|
||||
scope_profile: Literal["google_sheets", "gmail", "google_drive"] | None = Field(
|
||||
default=None,
|
||||
description="Allowed Google OAuth scope profile to request. Defaults to google_sheets.",
|
||||
examples=["google_sheets", "gmail"],
|
||||
examples=["google_sheets", "gmail", "google_drive"],
|
||||
)
|
||||
app_origin: str | None = Field(
|
||||
default=None,
|
||||
|
|
|
|||
251
skyvern/forge/sdk/services/google_drive_service.py
Normal file
251
skyvern/forge/sdk/services/google_drive_service.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from mimetypes import guess_type
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from skyvern.config import settings
|
||||
|
||||
DRIVE_UPLOAD_API_BASE = "https://www.googleapis.com/upload/drive/v3"
|
||||
DRIVE_MULTIPART_UPLOAD_MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
_DEFAULT_BACKOFF_SECONDS = 1.0
|
||||
|
||||
|
||||
class GoogleDriveAPIError(RuntimeError):
|
||||
def __init__(self, *, status: int, code: str | None, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UploadedDriveFile:
|
||||
id: str
|
||||
web_view_link: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GoogleDriveMultipartUploadRequest:
|
||||
target_url: str
|
||||
headers: dict[str, str]
|
||||
content: bytes
|
||||
|
||||
|
||||
def _compute_backoff(attempt: int, retry_after: str | None) -> float:
|
||||
if retry_after:
|
||||
value = retry_after.strip()
|
||||
try:
|
||||
return max(0.0, float(value))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
target = parsedate_to_datetime(value)
|
||||
except (TypeError, ValueError):
|
||||
target = None
|
||||
if target is not None:
|
||||
if target.tzinfo is None:
|
||||
target = target.replace(tzinfo=timezone.utc)
|
||||
return max(0.0, (target - datetime.now(timezone.utc)).total_seconds())
|
||||
return _DEFAULT_BACKOFF_SECONDS * (2 ** (attempt - 1))
|
||||
|
||||
|
||||
def _raise_for_error(response: httpx.Response) -> None:
|
||||
if response.is_success:
|
||||
return
|
||||
status = response.status_code
|
||||
try:
|
||||
payload: Any = response.json() or {}
|
||||
except ValueError:
|
||||
raise GoogleDriveAPIError(
|
||||
status=status,
|
||||
code=None,
|
||||
message=response.text[:500] or "Google Drive API error",
|
||||
) from None
|
||||
err = payload.get("error") if isinstance(payload, dict) else {}
|
||||
if not isinstance(err, dict):
|
||||
raise GoogleDriveAPIError(status=status, code=None, message="Google Drive API error")
|
||||
message = err.get("message") or "Google Drive API error"
|
||||
details = err.get("errors")
|
||||
code: str | None = None
|
||||
if isinstance(details, list) and details and isinstance(details[0], dict):
|
||||
code = details[0].get("reason")
|
||||
if status == 403 and code in {"insufficientPermissions", "insufficientScopes"}:
|
||||
code = "reconnect_required"
|
||||
raise GoogleDriveAPIError(status=status, code=code, message=message)
|
||||
|
||||
|
||||
def extract_folder_id(value: str) -> str:
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
raise ValueError("Google Drive folder ID is required")
|
||||
|
||||
parsed = urlparse(candidate)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
hostname = parsed.hostname or ""
|
||||
if parsed.scheme != "https" or not (hostname == "google.com" or hostname.endswith(".google.com")):
|
||||
raise ValueError("Google Drive folder URL must be an https://*.google.com URL")
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
for index, part in enumerate(parts):
|
||||
if part == "folders" and index + 1 < len(parts):
|
||||
return parts[index + 1]
|
||||
raise ValueError("Google Drive folder URL must contain /folders/{folder_id}")
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def _assert_multipart_upload_size(file_path: str, body_size: int | None = None) -> None:
|
||||
file_size = Path(file_path).stat().st_size
|
||||
if file_size > DRIVE_MULTIPART_UPLOAD_MAX_BYTES:
|
||||
raise GoogleDriveAPIError(
|
||||
status=413,
|
||||
code="file_too_large",
|
||||
message=(
|
||||
"Google Drive multipart uploads are limited to 5 MB. "
|
||||
"Use a smaller file or wait for resumable Drive upload support."
|
||||
),
|
||||
)
|
||||
if body_size is not None and body_size > DRIVE_MULTIPART_UPLOAD_MAX_BYTES:
|
||||
raise GoogleDriveAPIError(
|
||||
status=413,
|
||||
code="multipart_body_too_large",
|
||||
message=(
|
||||
"Google Drive multipart uploads are limited to 5 MB including metadata. "
|
||||
"Use a smaller file or wait for resumable Drive upload support."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _multipart_body(
|
||||
*,
|
||||
metadata: dict[str, Any],
|
||||
file_path: str,
|
||||
content_type: str,
|
||||
boundary: str,
|
||||
) -> bytes:
|
||||
metadata_bytes = json.dumps(metadata, separators=(",", ":")).encode("utf-8")
|
||||
return b"".join(
|
||||
[
|
||||
f"--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n".encode(),
|
||||
metadata_bytes,
|
||||
f"\r\n--{boundary}\r\nContent-Type: {content_type}\r\n\r\n".encode(),
|
||||
Path(file_path).read_bytes(),
|
||||
f"\r\n--{boundary}--\r\n".encode(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_multipart_upload_request(
|
||||
*,
|
||||
access_token: str,
|
||||
file_path: str,
|
||||
folder_id: str,
|
||||
) -> GoogleDriveMultipartUploadRequest:
|
||||
"""Build a bounded Google Drive multipart upload request body.
|
||||
|
||||
``folder_id`` is expected to be a normalized folder ID. Call
|
||||
``extract_folder_id`` on user-entered values before invoking this helper.
|
||||
"""
|
||||
_assert_multipart_upload_size(file_path)
|
||||
file_name = Path(file_path).name
|
||||
content_type = guess_type(file_path)[0] or "application/octet-stream"
|
||||
metadata = {"name": file_name, "parents": [folder_id]}
|
||||
boundary = f"skyvern-{uuid.uuid4().hex}"
|
||||
content = _multipart_body(
|
||||
metadata=metadata,
|
||||
file_path=file_path,
|
||||
content_type=content_type,
|
||||
boundary=boundary,
|
||||
)
|
||||
_assert_multipart_upload_size(file_path, len(content))
|
||||
query = urlencode({"uploadType": "multipart", "fields": "id,name,webViewLink", "supportsAllDrives": "true"})
|
||||
return GoogleDriveMultipartUploadRequest(
|
||||
target_url=f"{DRIVE_UPLOAD_API_BASE}/files?{query}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": f"multipart/related; boundary={boundary}",
|
||||
},
|
||||
content=content,
|
||||
)
|
||||
|
||||
|
||||
def uploaded_file_from_payload(payload: Any) -> UploadedDriveFile:
|
||||
if not isinstance(payload, dict):
|
||||
raise GoogleDriveAPIError(status=500, code="malformed_response", message="Malformed Drive upload response")
|
||||
file_id = payload.get("id")
|
||||
if not file_id:
|
||||
raise GoogleDriveAPIError(status=500, code="malformed_response", message="Drive response missing file id")
|
||||
return UploadedDriveFile(
|
||||
id=file_id,
|
||||
web_view_link=payload.get("webViewLink"),
|
||||
)
|
||||
|
||||
|
||||
async def _post_multipart_with_retry(
|
||||
client: httpx.AsyncClient,
|
||||
request: GoogleDriveMultipartUploadRequest,
|
||||
) -> httpx.Response:
|
||||
"""POST a Drive multipart upload without replaying ambiguous creates.
|
||||
|
||||
Google Drive files.create is not idempotent. Retrying after Drive has seen
|
||||
the POST can create duplicate files, so only retry failures that occur
|
||||
while acquiring a connection and fail all ambiguous mutation outcomes.
|
||||
"""
|
||||
max_attempts = max(1, settings.GOOGLE_DRIVE_API_MAX_RETRIES)
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
return await client.post(
|
||||
request.target_url,
|
||||
headers=request.headers,
|
||||
content=request.content,
|
||||
)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
|
||||
if attempt == max_attempts:
|
||||
raise GoogleDriveAPIError(
|
||||
status=503,
|
||||
code="upstream_unavailable",
|
||||
message=f"Google Drive upload connection failure: {exc}",
|
||||
) from exc
|
||||
await asyncio.sleep(_compute_backoff(attempt, None))
|
||||
continue
|
||||
except (httpx.TransportError, httpx.TimeoutException) as exc:
|
||||
raise GoogleDriveAPIError(
|
||||
status=503,
|
||||
code="ambiguous_upload_status",
|
||||
message=(
|
||||
"Google Drive upload status is unknown after a transport failure. "
|
||||
"Not retrying automatically to avoid creating duplicate files."
|
||||
),
|
||||
) from exc
|
||||
raise AssertionError("Drive upload retry loop exited without a response")
|
||||
|
||||
|
||||
async def upload_file(
|
||||
*,
|
||||
access_token: str,
|
||||
file_path: str,
|
||||
folder_id: str,
|
||||
) -> UploadedDriveFile:
|
||||
request = build_multipart_upload_request(
|
||||
access_token=access_token,
|
||||
file_path=file_path,
|
||||
folder_id=folder_id,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=settings.GOOGLE_DRIVE_API_TIMEOUT_SECONDS) as client:
|
||||
response = await _post_multipart_with_retry(client, request)
|
||||
|
||||
_raise_for_error(response)
|
||||
payload = response.json() or {}
|
||||
return uploaded_file_from_payload(payload)
|
||||
|
|
@ -58,11 +58,17 @@ GOOGLE_SHEETS_SCOPES: tuple[str, ...] = (
|
|||
)
|
||||
GOOGLE_GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
GOOGLE_GMAIL_SCOPES: tuple[str, ...] = (GOOGLE_GMAIL_READONLY_SCOPE,)
|
||||
# Drive uploads accept pasted folder IDs/URLs. drive.file cannot write to
|
||||
# arbitrary existing folders unless the app created or Picker-selected them, so
|
||||
# this profile intentionally uses full Drive scope until a Picker flow exists.
|
||||
GOOGLE_DRIVE_SCOPES: tuple[str, ...] = ("https://www.googleapis.com/auth/drive",)
|
||||
GOOGLE_OAUTH_SCOPE_PROFILE_SHEETS = "google_sheets"
|
||||
GOOGLE_OAUTH_SCOPE_PROFILE_GMAIL = "gmail"
|
||||
GOOGLE_OAUTH_SCOPE_PROFILE_DRIVE = "google_drive"
|
||||
GOOGLE_OAUTH_SCOPE_PROFILES: dict[str, tuple[str, ...]] = {
|
||||
GOOGLE_OAUTH_SCOPE_PROFILE_SHEETS: GOOGLE_SHEETS_SCOPES,
|
||||
GOOGLE_OAUTH_SCOPE_PROFILE_GMAIL: GOOGLE_GMAIL_SCOPES,
|
||||
GOOGLE_OAUTH_SCOPE_PROFILE_DRIVE: GOOGLE_DRIVE_SCOPES,
|
||||
}
|
||||
|
||||
CONSENT_TTL_SECONDS = 600
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ from skyvern.forge.sdk.models import Step, StepStatus
|
|||
from skyvern.forge.sdk.schemas.files import FileInfo
|
||||
from skyvern.forge.sdk.schemas.task_v2 import TaskV2Status
|
||||
from skyvern.forge.sdk.schemas.tasks import Task, TaskOutput, TaskStatus
|
||||
from skyvern.forge.sdk.services import google_drive_service, google_oauth_service
|
||||
from skyvern.forge.sdk.services.bitwarden import BitwardenConstants
|
||||
from skyvern.forge.sdk.services.credentials import AzureVaultConstants, OnePasswordConstants, generate_totp_code
|
||||
from skyvern.forge.sdk.settings_manager import SettingsManager
|
||||
|
|
@ -4814,6 +4815,8 @@ class FileUploadBlock(Block):
|
|||
azure_storage_account_name: str | None = None
|
||||
azure_storage_account_key: str | None = None
|
||||
azure_blob_container_name: str | None = None
|
||||
google_credential_id: str | None = None
|
||||
google_drive_folder_id: str | None = None
|
||||
path: str | None = None
|
||||
continue_on_empty: bool = Field(
|
||||
default=False,
|
||||
|
|
@ -4852,6 +4855,12 @@ class FileUploadBlock(Block):
|
|||
if self.azure_blob_container_name and workflow_run_context.has_parameter(self.azure_blob_container_name):
|
||||
parameters.append(workflow_run_context.get_parameter(self.azure_blob_container_name))
|
||||
|
||||
if self.google_credential_id and workflow_run_context.has_parameter(self.google_credential_id):
|
||||
parameters.append(workflow_run_context.get_parameter(self.google_credential_id))
|
||||
|
||||
if self.google_drive_folder_id and workflow_run_context.has_parameter(self.google_drive_folder_id):
|
||||
parameters.append(workflow_run_context.get_parameter(self.google_drive_folder_id))
|
||||
|
||||
return parameters
|
||||
|
||||
def format_potential_template_parameters(self, workflow_run_context: WorkflowRunContext) -> None:
|
||||
|
|
@ -4882,6 +4891,14 @@ class FileUploadBlock(Block):
|
|||
self.azure_blob_container_name = self.format_block_parameter_template_from_workflow_run_context(
|
||||
self.azure_blob_container_name, workflow_run_context
|
||||
)
|
||||
if self.google_credential_id:
|
||||
self.google_credential_id = self.format_block_parameter_template_from_workflow_run_context(
|
||||
self.google_credential_id, workflow_run_context
|
||||
)
|
||||
if self.google_drive_folder_id:
|
||||
self.google_drive_folder_id = self.format_block_parameter_template_from_workflow_run_context(
|
||||
self.google_drive_folder_id, workflow_run_context
|
||||
)
|
||||
|
||||
def _get_s3_uri(self, workflow_run_id: str, path: str) -> str:
|
||||
folder_path = self.path or f"{workflow_run_id}"
|
||||
|
|
@ -4947,6 +4964,20 @@ class FileUploadBlock(Block):
|
|||
azure_blob_name=blob_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_google_drive_destination(
|
||||
*,
|
||||
access_token: str,
|
||||
folder_id: str,
|
||||
) -> FileUploadDestination:
|
||||
return FileUploadDestination(
|
||||
storage_type=FileStorageType.GOOGLE_DRIVE,
|
||||
customer_uri=f"https://drive.google.com/drive/folders/{folder_id}",
|
||||
sdk_uri=f"https://drive.google.com/drive/folders/{folder_id}",
|
||||
google_access_token=access_token,
|
||||
google_drive_folder_id=folder_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _candidate_download_signal_run_ids(
|
||||
*,
|
||||
|
|
@ -5162,6 +5193,11 @@ class FileUploadBlock(Block):
|
|||
missing_parameters.append("azure_storage_account_key")
|
||||
if not self.azure_blob_container_name or self.azure_blob_container_name == "":
|
||||
missing_parameters.append("azure_blob_container_name")
|
||||
elif self.storage_type == FileStorageType.GOOGLE_DRIVE:
|
||||
if not self.google_credential_id:
|
||||
missing_parameters.append("google_credential_id")
|
||||
if not self.google_drive_folder_id:
|
||||
missing_parameters.append("google_drive_folder_id")
|
||||
else:
|
||||
return await self.build_block_result(
|
||||
success=False,
|
||||
|
|
@ -5204,7 +5240,7 @@ class FileUploadBlock(Block):
|
|||
files_to_upload = []
|
||||
max_file_count = (
|
||||
MAX_UPLOAD_FILE_COUNT
|
||||
if self.storage_type == FileStorageType.S3
|
||||
if self.storage_type in {FileStorageType.S3, FileStorageType.GOOGLE_DRIVE}
|
||||
else AZURE_BLOB_STORAGE_MAX_UPLOAD_FILE_COUNT
|
||||
)
|
||||
files_to_upload = self._get_files_to_upload_from_download_dir(
|
||||
|
|
@ -5337,6 +5373,40 @@ class FileUploadBlock(Block):
|
|||
)
|
||||
uploaded_uris.append(customer_uri)
|
||||
LOG.info("FileUploadBlock File(s) uploaded to Azure Blob Storage", file_path=self.path)
|
||||
elif self.storage_type == FileStorageType.GOOGLE_DRIVE:
|
||||
org_id = organization_id or workflow_run_context.organization_id
|
||||
if not org_id:
|
||||
raise ValueError("organization_id is required for Google Drive uploads")
|
||||
google_credential_id = (
|
||||
workflow_run_context.get_original_secret_value_or_none(self.google_credential_id)
|
||||
or self.google_credential_id
|
||||
)
|
||||
if not google_credential_id:
|
||||
raise ValueError("Google credential id is required")
|
||||
|
||||
google_credentials = await app.AGENT_FUNCTION.get_google_workspace_credentials(
|
||||
organization_id=org_id,
|
||||
credential_id=google_credential_id,
|
||||
required_scopes=list(google_oauth_service.GOOGLE_DRIVE_SCOPES),
|
||||
)
|
||||
if not google_credentials or not google_credentials.token:
|
||||
raise ValueError("Google Drive credential is not connected or is missing required scopes")
|
||||
|
||||
folder_id = google_drive_service.extract_folder_id(self.google_drive_folder_id or "")
|
||||
for file_path in files_to_upload:
|
||||
LOG.info("FileUploadBlock Uploading file to Google Drive", file_path=file_path)
|
||||
destination = self._build_google_drive_destination(
|
||||
access_token=google_credentials.token,
|
||||
folder_id=folder_id,
|
||||
)
|
||||
customer_uri = await app.AGENT_FUNCTION.upload_file_to_customer_storage(
|
||||
file_path=file_path,
|
||||
destination=destination,
|
||||
organization_id=org_id,
|
||||
run_id=workflow_run_id,
|
||||
)
|
||||
uploaded_uris.append(customer_uri)
|
||||
LOG.info("FileUploadBlock File(s) uploaded to Google Drive", file_path=self.path)
|
||||
else:
|
||||
# This case should ideally be caught by the initial validation
|
||||
raise ValueError(f"Unsupported storage type: {self.storage_type}")
|
||||
|
|
|
|||
|
|
@ -631,6 +631,8 @@ def block_yaml_to_block(
|
|||
azure_storage_account_name=block_yaml.azure_storage_account_name,
|
||||
azure_storage_account_key=block_yaml.azure_storage_account_key,
|
||||
azure_blob_container_name=block_yaml.azure_blob_container_name,
|
||||
google_credential_id=block_yaml.google_credential_id,
|
||||
google_drive_folder_id=block_yaml.google_drive_folder_id,
|
||||
path=block_yaml.path,
|
||||
)
|
||||
elif block_yaml.block_type == BlockType.SEND_EMAIL:
|
||||
|
|
|
|||
|
|
@ -503,6 +503,7 @@ class PDFFormat(StrEnum):
|
|||
class FileStorageType(StrEnum):
|
||||
S3 = "s3"
|
||||
AZURE = "azure"
|
||||
GOOGLE_DRIVE = "google_drive"
|
||||
|
||||
|
||||
class FileUploadDestination(BaseModel):
|
||||
|
|
@ -528,6 +529,9 @@ class FileUploadDestination(BaseModel):
|
|||
azure_blob_container_name: str | None = None
|
||||
azure_blob_name: str | None = None
|
||||
|
||||
google_access_token: str | None = None
|
||||
google_drive_folder_id: str | None = None
|
||||
|
||||
|
||||
class ParameterYAML(BaseModel, abc.ABC):
|
||||
parameter_type: ParameterType
|
||||
|
|
@ -906,6 +910,8 @@ class FileUploadBlockYAML(BlockYAML):
|
|||
azure_storage_account_key: str | None = None
|
||||
azure_blob_container_name: str | None = None
|
||||
azure_folder_path: str | None = None
|
||||
google_credential_id: str | None = None
|
||||
google_drive_folder_id: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2850,6 +2850,8 @@ async def upload_file(
|
|||
azure_storage_account_name: str | None = None,
|
||||
azure_storage_account_key: str | None = None,
|
||||
azure_blob_container_name: str | None = None,
|
||||
google_credential_id: str | None = None,
|
||||
google_drive_folder_id: str | None = None,
|
||||
path: str | None = None,
|
||||
) -> None:
|
||||
block_validation_output = await _validate_and_get_output_parameter(label, parameters)
|
||||
|
|
@ -2867,6 +2869,10 @@ async def upload_file(
|
|||
azure_storage_account_key = _render_template_with_label(azure_storage_account_key, label)
|
||||
if azure_blob_container_name:
|
||||
azure_blob_container_name = _render_template_with_label(azure_blob_container_name, label)
|
||||
if google_credential_id:
|
||||
google_credential_id = _render_template_with_label(google_credential_id, label)
|
||||
if google_drive_folder_id:
|
||||
google_drive_folder_id = _render_template_with_label(google_drive_folder_id, label)
|
||||
if path:
|
||||
path = _render_template_with_label(path, label)
|
||||
file_upload_block = FileUploadBlock(
|
||||
|
|
@ -2881,6 +2887,8 @@ async def upload_file(
|
|||
azure_storage_account_name=azure_storage_account_name,
|
||||
azure_storage_account_key=azure_storage_account_key,
|
||||
azure_blob_container_name=azure_blob_container_name,
|
||||
google_credential_id=google_credential_id,
|
||||
google_drive_folder_id=google_drive_folder_id,
|
||||
path=path,
|
||||
)
|
||||
await file_upload_block.execute_safe(
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ def _azure_destination() -> FileUploadDestination:
|
|||
)
|
||||
|
||||
|
||||
def _google_drive_destination() -> FileUploadDestination:
|
||||
return FileUploadDestination(
|
||||
storage_type=FileStorageType.GOOGLE_DRIVE,
|
||||
customer_uri="https://drive.google.com/drive/folders/folder_123",
|
||||
sdk_uri="https://drive.google.com/drive/folders/folder_123",
|
||||
google_access_token="at-1",
|
||||
google_drive_folder_id="folder_123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def small_file(tmp_path: Path) -> Path:
|
||||
fp = tmp_path / "f.bin"
|
||||
|
|
@ -119,6 +129,32 @@ async def test_azure_missing_creds_raises(small_file: Path) -> None:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_direct_path_calls_drive_service(small_file: Path) -> None:
|
||||
destination = _google_drive_destination()
|
||||
uploaded = MagicMock()
|
||||
uploaded.id = "file_123"
|
||||
uploaded.web_view_link = "https://drive.google.com/file/d/file_123/view"
|
||||
|
||||
with patch(
|
||||
"skyvern.forge.agent_functions.google_drive_service.upload_file",
|
||||
new_callable=AsyncMock,
|
||||
return_value=uploaded,
|
||||
) as mock_upload:
|
||||
result = await AgentFunction().upload_file_to_customer_storage(
|
||||
file_path=str(small_file),
|
||||
destination=destination,
|
||||
organization_id="o_1",
|
||||
)
|
||||
|
||||
assert result == "https://drive.google.com/file/d/file_123/view"
|
||||
mock_upload.assert_awaited_once_with(
|
||||
access_token="at-1",
|
||||
file_path=str(small_file),
|
||||
folder_id="folder_123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_cap_enforced_on_direct_path(small_file: Path) -> None:
|
||||
destination = _s3_destination()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from typing import Any, Awaitable, Callable
|
||||
from unittest.mock import AsyncMock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
|
@ -9,8 +9,8 @@ from pydantic import ValidationError
|
|||
from sqlalchemy.sql.elements import BindParameter
|
||||
|
||||
from skyvern.forge.sdk.encrypt.base import EncryptMethod
|
||||
from skyvern.forge.sdk.schemas.google_oauth import UpdateGoogleOAuthCredentialRequest
|
||||
from skyvern.forge.sdk.services import google_oauth_service
|
||||
from skyvern.forge.sdk.schemas.google_oauth import CreateGoogleOAuthAuthorizeRequest, UpdateGoogleOAuthCredentialRequest
|
||||
from skyvern.forge.sdk.services import google_drive_service, google_oauth_service
|
||||
|
||||
|
||||
def _unwrap_bind(value: Any) -> Any:
|
||||
|
|
@ -22,6 +22,20 @@ def _default_scopes_list() -> list[str]:
|
|||
return list(google_oauth_service.GOOGLE_SHEETS_SCOPES)
|
||||
|
||||
|
||||
def _install_google_drive_transport(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
handler: Callable[[httpx.Request], httpx.Response | Awaitable[httpx.Response]],
|
||||
) -> None:
|
||||
transport = httpx.MockTransport(handler)
|
||||
real_client = httpx.AsyncClient
|
||||
|
||||
def fake_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
|
||||
kwargs["transport"] = transport
|
||||
return real_client(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(google_drive_service.httpx, "AsyncClient", fake_async_client)
|
||||
|
||||
|
||||
def test_coerce_scopes_accepts_strings_and_iterables() -> None:
|
||||
assert google_oauth_service._coerce_scopes("https://a/scope https://b/scope") == [
|
||||
"https://a/scope",
|
||||
|
|
@ -43,14 +57,226 @@ def test_google_sheets_scopes_includes_drive_file_and_metadata_readonly() -> Non
|
|||
assert "https://www.googleapis.com/auth/drive.metadata.readonly" in scopes
|
||||
|
||||
|
||||
def test_google_drive_scope_profile_uses_full_drive_scope_for_folder_uploads() -> None:
|
||||
scopes = google_oauth_service.scopes_for_profile("google_drive")
|
||||
assert scopes == [
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
]
|
||||
|
||||
|
||||
def test_google_oauth_authorize_request_accepts_google_drive_scope_profile() -> None:
|
||||
request = CreateGoogleOAuthAuthorizeRequest(
|
||||
redirect_uri="https://app.example.com/google/callback",
|
||||
scope_profile="google_drive",
|
||||
)
|
||||
|
||||
assert request.scope_profile == "google_drive"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("folder_123", "folder_123"),
|
||||
("https://drive.google.com/drive/u/0/folders/folder_123", "folder_123"),
|
||||
],
|
||||
)
|
||||
def test_google_drive_extract_folder_id(value: str, expected: str) -> None:
|
||||
assert google_drive_service.extract_folder_id(value) == expected
|
||||
|
||||
|
||||
def test_google_drive_extract_folder_id_rejects_non_folder_url() -> None:
|
||||
with pytest.raises(ValueError, match="folder URL"):
|
||||
google_drive_service.extract_folder_id("https://drive.google.com/file/d/file_123/view")
|
||||
|
||||
|
||||
def test_google_drive_extract_folder_id_rejects_non_google_folder_url() -> None:
|
||||
with pytest.raises(ValueError, match=r"https://\*\.google\.com"):
|
||||
google_drive_service.extract_folder_id("https://attacker.example.com/folders/folder_123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_uploads_multipart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
source = tmp_path / "report.txt"
|
||||
source.write_text("hello-drive")
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
captured["url"] = url
|
||||
captured["auth"] = request.headers.get("Authorization")
|
||||
captured["content_type"] = request.headers["Content-Type"]
|
||||
captured["body"] = await request.aread()
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "file_123",
|
||||
"name": "report.txt",
|
||||
"webViewLink": "https://drive.google.com/file/d/file_123/view",
|
||||
},
|
||||
)
|
||||
|
||||
_install_google_drive_transport(monkeypatch, handler)
|
||||
|
||||
uploaded = await google_drive_service.upload_file(
|
||||
access_token="at-1",
|
||||
file_path=str(source),
|
||||
folder_id="folder_123",
|
||||
)
|
||||
|
||||
assert uploaded.id == "file_123"
|
||||
assert captured["auth"] == "Bearer at-1"
|
||||
assert "/upload/drive/v3/files" in captured["url"]
|
||||
assert "uploadType=multipart" in captured["url"]
|
||||
assert "supportsAllDrives=true" in captured["url"]
|
||||
assert str(captured["content_type"]).startswith("multipart/related; boundary=skyvern-")
|
||||
body = captured["body"]
|
||||
assert isinstance(body, bytes)
|
||||
assert b'"parents":["folder_123"]' in body
|
||||
assert b'"name":"report.txt"' in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_upload_does_not_retry_retryable_create_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
source = tmp_path / "report.txt"
|
||||
source.write_text("hello-drive")
|
||||
calls = 0
|
||||
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls > 1:
|
||||
return httpx.Response(
|
||||
200, json={"id": "file_123", "webViewLink": "https://drive.google.com/file/d/123/view"}
|
||||
)
|
||||
return httpx.Response(503, headers={"Retry-After": "0"}, json={"error": {"message": "try later"}})
|
||||
|
||||
_install_google_drive_transport(monkeypatch, handler)
|
||||
sleep_mock = AsyncMock()
|
||||
monkeypatch.setattr(google_drive_service.asyncio, "sleep", sleep_mock)
|
||||
|
||||
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
|
||||
await google_drive_service.upload_file(
|
||||
access_token="at-1",
|
||||
file_path=str(source),
|
||||
folder_id="folder_123",
|
||||
)
|
||||
|
||||
assert exc_info.value.status == 503
|
||||
assert calls == 1
|
||||
sleep_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_upload_retries_connection_failures_before_request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
source = tmp_path / "report.txt"
|
||||
source.write_text("hello-drive")
|
||||
calls = 0
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise httpx.ConnectError("connect failed", request=request)
|
||||
return httpx.Response(200, json={"id": "file_123", "webViewLink": "https://drive.google.com/file/d/123/view"})
|
||||
|
||||
_install_google_drive_transport(monkeypatch, handler)
|
||||
sleep_mock = AsyncMock()
|
||||
monkeypatch.setattr(google_drive_service.asyncio, "sleep", sleep_mock)
|
||||
|
||||
uploaded = await google_drive_service.upload_file(
|
||||
access_token="at-1",
|
||||
file_path=str(source),
|
||||
folder_id="folder_123",
|
||||
)
|
||||
|
||||
assert uploaded.id == "file_123"
|
||||
assert calls == 2
|
||||
sleep_mock.assert_awaited_once_with(1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_upload_does_not_retry_ambiguous_transport_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
source = tmp_path / "report.txt"
|
||||
source.write_text("hello-drive")
|
||||
calls = 0
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise httpx.ReadTimeout("read timed out", request=request)
|
||||
|
||||
_install_google_drive_transport(monkeypatch, handler)
|
||||
sleep_mock = AsyncMock()
|
||||
monkeypatch.setattr(google_drive_service.asyncio, "sleep", sleep_mock)
|
||||
|
||||
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
|
||||
await google_drive_service.upload_file(
|
||||
access_token="at-1",
|
||||
file_path=str(source),
|
||||
folder_id="folder_123",
|
||||
)
|
||||
|
||||
assert exc_info.value.status == 503
|
||||
assert exc_info.value.code == "ambiguous_upload_status"
|
||||
assert calls == 1
|
||||
sleep_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_upload_rejects_multipart_files_over_google_limit(tmp_path) -> None:
|
||||
source = tmp_path / "large.bin"
|
||||
source.write_bytes(b"x" * (google_drive_service.DRIVE_MULTIPART_UPLOAD_MAX_BYTES + 1))
|
||||
|
||||
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
|
||||
await google_drive_service.upload_file(
|
||||
access_token="at-1",
|
||||
file_path=str(source),
|
||||
folder_id="folder_123",
|
||||
)
|
||||
|
||||
assert exc_info.value.status == 413
|
||||
assert exc_info.value.code == "file_too_large"
|
||||
|
||||
|
||||
def test_google_drive_maps_insufficient_scope_to_reconnect() -> None:
|
||||
response = httpx.Response(
|
||||
403,
|
||||
json={
|
||||
"error": {
|
||||
"message": "Request had insufficient authentication scopes.",
|
||||
"errors": [{"reason": "insufficientPermissions"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
with pytest.raises(google_drive_service.GoogleDriveAPIError) as exc_info:
|
||||
google_drive_service._raise_for_error(response)
|
||||
|
||||
assert exc_info.value.status == 403
|
||||
assert exc_info.value.code == "reconnect_required"
|
||||
|
||||
|
||||
def test_sheets_api_runtime_defaults_match_previous_hardcoded_values() -> None:
|
||||
"""Sheets timeout/retry settings default to known values so unset envs
|
||||
"""Google API timeout/retry settings default to known values so unset envs
|
||||
produce no behavior change for upgrading deployments."""
|
||||
from skyvern.config import Settings
|
||||
|
||||
fresh = Settings()
|
||||
assert fresh.GOOGLE_SHEETS_API_TIMEOUT_SECONDS == 30.0
|
||||
assert fresh.GOOGLE_SHEETS_API_MAX_RETRIES == 3
|
||||
assert fresh.GOOGLE_DRIVE_API_TIMEOUT_SECONDS == 30.0
|
||||
assert fresh.GOOGLE_DRIVE_API_MAX_RETRIES == 3
|
||||
|
||||
|
||||
def test_build_authorize_url_includes_required_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
|
|
@ -967,6 +967,69 @@ async def test_file_upload_block_continue_on_empty_succeeds(tmp_path) -> None:
|
|||
mock_app.AGENT_FUNCTION.upload_file_to_customer_storage.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_upload_block_uploads_downloads_to_google_drive(tmp_path) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from skyvern.forge.sdk.workflow.models.block import FileUploadBlock
|
||||
from skyvern.schemas.workflows import FileStorageType
|
||||
|
||||
download_dir = tmp_path / "wr_drive"
|
||||
download_dir.mkdir()
|
||||
source = download_dir / "report.txt"
|
||||
source.write_text("drive upload")
|
||||
|
||||
block = FileUploadBlock.model_construct(
|
||||
label="upload",
|
||||
storage_type=FileStorageType.GOOGLE_DRIVE,
|
||||
google_credential_id="goac_123",
|
||||
google_drive_folder_id="https://drive.google.com/drive/folders/folder_123",
|
||||
path=None,
|
||||
continue_on_empty=False,
|
||||
)
|
||||
sentinel = object()
|
||||
workflow_run_context = MagicMock()
|
||||
workflow_run_context.organization_id = "org_1"
|
||||
workflow_run_context.get_original_secret_value_or_none.return_value = None
|
||||
|
||||
with (
|
||||
patch.object(FileUploadBlock, "get_workflow_run_context", return_value=workflow_run_context),
|
||||
patch.object(FileUploadBlock, "format_potential_template_parameters", return_value=None),
|
||||
patch.object(FileUploadBlock, "record_output_parameter_value", new_callable=AsyncMock),
|
||||
patch.object(
|
||||
FileUploadBlock, "build_block_result", new_callable=AsyncMock, return_value=sentinel
|
||||
) as mock_result,
|
||||
patch(
|
||||
"skyvern.forge.sdk.workflow.models.block.get_path_for_workflow_download_directory",
|
||||
return_value=download_dir,
|
||||
),
|
||||
patch("skyvern.forge.sdk.workflow.models.block.skyvern_context.current", return_value=None),
|
||||
patch("skyvern.forge.sdk.workflow.models.block.app") as mock_app,
|
||||
):
|
||||
mock_app.AGENT_FUNCTION.get_google_workspace_credentials = AsyncMock(return_value=SimpleNamespace(token="at-1"))
|
||||
mock_app.AGENT_FUNCTION.upload_file_to_customer_storage = AsyncMock(
|
||||
return_value="https://drive.google.com/file/d/file_123/view"
|
||||
)
|
||||
result = await block.execute(
|
||||
workflow_run_id="wr_drive",
|
||||
workflow_run_block_id="wrb_x",
|
||||
organization_id="org_1",
|
||||
)
|
||||
|
||||
assert result is sentinel
|
||||
assert mock_result.await_args.kwargs["success"] is True
|
||||
assert mock_result.await_args.kwargs["output_parameter_value"] == ["https://drive.google.com/file/d/file_123/view"]
|
||||
mock_app.AGENT_FUNCTION.upload_file_to_customer_storage.assert_awaited_once()
|
||||
upload_kwargs = mock_app.AGENT_FUNCTION.upload_file_to_customer_storage.await_args.kwargs
|
||||
assert upload_kwargs["file_path"] == str(source)
|
||||
assert upload_kwargs["organization_id"] == "org_1"
|
||||
assert upload_kwargs["run_id"] == "wr_drive"
|
||||
destination = upload_kwargs["destination"]
|
||||
assert destination.storage_type == FileStorageType.GOOGLE_DRIVE
|
||||
assert destination.google_access_token == "at-1"
|
||||
assert destination.google_drive_folder_id == "folder_123"
|
||||
|
||||
|
||||
def test_resolve_run_download_id_preserves_task_id_tail() -> None:
|
||||
"""CORR-1: mirrors handler.py fallback_run_id=task.workflow_run_id or task.task_id — when both
|
||||
context and workflow_run_id are absent, the task_id tail must still be resolved (not None)."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue