mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 08:57:31 +08:00
Merge branch 'main' into docs-1.7-release
This commit is contained in:
File diff suppressed because one or more lines are too long
@ -324,3 +324,141 @@ class TestSaveToFileComponent(ComponentTestBaseWithoutClient):
|
||||
# Clean up temp file
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_credential_parsing_with_control_characters(self, component_class):
|
||||
"""Test that GCP service account JSON with literal newlines (control characters) can be parsed.
|
||||
|
||||
This tests the fix for the bug where pasted GCP service account JSON fails with:
|
||||
'Invalid control character at: line 1 column 183 (char 182)'
|
||||
"""
|
||||
component = component_class(_user_id=str(uuid4()))
|
||||
|
||||
# Simulate a GCP service account JSON with literal newlines in the private_key field.
|
||||
# Use a clearly fake, short key to avoid tripping secret scanners while preserving the newline pattern.
|
||||
fake_private_key = "-----BEGIN KEY-----\nFAKE\n-----END KEY-----\n"
|
||||
service_account_json = (
|
||||
f'{{"type": "service_account", "project_id": "test-project-123", "private_key": "{fake_private_key}"}}'
|
||||
)
|
||||
|
||||
message = Message(text="test content")
|
||||
component.set_attributes(
|
||||
{
|
||||
"input": message,
|
||||
"file_name": "test_gdrive_file",
|
||||
"gdrive_format": "txt",
|
||||
"storage_location": [{"name": "Google Drive"}],
|
||||
"service_account_key": service_account_json,
|
||||
"folder_id": "test_folder_id_123",
|
||||
}
|
||||
)
|
||||
|
||||
# Mock Google Drive dependencies
|
||||
with (
|
||||
patch("google.oauth2.service_account.Credentials.from_service_account_info") as mock_creds,
|
||||
patch("googleapiclient.discovery.build") as mock_build,
|
||||
):
|
||||
mock_drive_service = MagicMock()
|
||||
mock_build.return_value = mock_drive_service
|
||||
|
||||
# Mock the file upload response
|
||||
mock_drive_service.files().create().execute.return_value = {"id": "file123"}
|
||||
|
||||
result = await component.save_to_file()
|
||||
|
||||
# Verify credentials were parsed successfully (should not raise JSONDecodeError)
|
||||
mock_creds.assert_called_once()
|
||||
creds_dict = mock_creds.call_args[0][0]
|
||||
|
||||
# Verify the parsed credentials have the expected structure
|
||||
assert creds_dict["type"] == "service_account"
|
||||
assert creds_dict["project_id"] == "test-project-123"
|
||||
assert "private_key" in creds_dict
|
||||
assert "BEGIN KEY" in creds_dict["private_key"]
|
||||
|
||||
# Verify successful upload message
|
||||
assert "successfully uploaded to Google Drive" in result.text
|
||||
assert "file123" in result.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_drive_credential_parsing_strategies(self, component_class):
|
||||
"""Test various GCP credential parsing strategies."""
|
||||
component = component_class(_user_id=str(uuid4()))
|
||||
|
||||
test_cases = [
|
||||
# Case 1: Normal JSON (should work)
|
||||
('{"type": "service_account", "project_id": "test"}', "Normal JSON"),
|
||||
# Case 2: JSON with literal newlines (the bug case)
|
||||
('{"type": "service_account", "private_key": "-----BEGIN\nKEY\n-----END"}', "With control chars"),
|
||||
# Case 3: JSON with extra whitespace
|
||||
(' \n{"type": "service_account", "project_id": "test"} \n', "With whitespace"),
|
||||
]
|
||||
|
||||
for service_account_json, test_name in test_cases:
|
||||
message = Message(text="test")
|
||||
component.set_attributes(
|
||||
{
|
||||
"input": message,
|
||||
"file_name": "test_file",
|
||||
"gdrive_format": "txt",
|
||||
"storage_location": [{"name": "Google Drive"}],
|
||||
"service_account_key": service_account_json,
|
||||
"folder_id": "test_folder",
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("google.oauth2.service_account.Credentials.from_service_account_info") as mock_creds,
|
||||
patch("googleapiclient.discovery.build") as mock_build,
|
||||
):
|
||||
mock_drive_service = MagicMock()
|
||||
mock_build.return_value = mock_drive_service
|
||||
mock_drive_service.files().create().execute.return_value = {"id": f"file_{test_name}"}
|
||||
|
||||
# Should not raise JSONDecodeError for any case
|
||||
await component.save_to_file()
|
||||
|
||||
# Verify credentials were parsed
|
||||
mock_creds.assert_called_once()
|
||||
creds_dict = mock_creds.call_args[0][0]
|
||||
assert isinstance(creds_dict, dict)
|
||||
assert creds_dict["type"] == "service_account"
|
||||
|
||||
mock_creds.reset_mock()
|
||||
|
||||
def test_append_mode_hidden_for_cloud_storage(self, component_class):
|
||||
"""Test that append_mode is hidden for AWS and Google Drive storage."""
|
||||
component = component_class()
|
||||
|
||||
# Test Local storage - append_mode should be visible
|
||||
build_config = {
|
||||
"file_name": {"show": False},
|
||||
"append_mode": {"show": False},
|
||||
"local_format": {"show": False},
|
||||
}
|
||||
result = component.update_build_config(build_config, [{"name": "Local"}], "storage_location")
|
||||
assert result["append_mode"]["show"] is True, "append_mode should be visible for Local storage"
|
||||
assert result["file_name"]["show"] is True
|
||||
assert result["local_format"]["show"] is True
|
||||
|
||||
# Test AWS storage - append_mode should be hidden
|
||||
build_config = {
|
||||
"file_name": {"show": False},
|
||||
"append_mode": {"show": False},
|
||||
"aws_format": {"show": False},
|
||||
}
|
||||
result = component.update_build_config(build_config, [{"name": "AWS"}], "storage_location")
|
||||
assert result["append_mode"]["show"] is False, "append_mode should be hidden for AWS storage"
|
||||
assert result["file_name"]["show"] is True
|
||||
assert result["aws_format"]["show"] is True
|
||||
|
||||
# Test Google Drive storage - append_mode should be hidden
|
||||
build_config = {
|
||||
"file_name": {"show": False},
|
||||
"append_mode": {"show": False},
|
||||
"gdrive_format": {"show": False},
|
||||
}
|
||||
result = component.update_build_config(build_config, [{"name": "Google Drive"}], "storage_location")
|
||||
assert result["append_mode"]["show"] is False, "append_mode should be hidden for Google Drive storage"
|
||||
assert result["file_name"]["show"] is True
|
||||
assert result["gdrive_format"]["show"] is True
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -77,7 +77,10 @@ class SaveToFileComponent(Component):
|
||||
BoolInput(
|
||||
name="append_mode",
|
||||
display_name="Append",
|
||||
info="Append to file if it exists (only for plain text formats). Disabled for binary formats like Excel.",
|
||||
info=(
|
||||
"Append to file if it exists (only for Local storage with plain text formats). "
|
||||
"Not supported for cloud storage (AWS/Google Drive)."
|
||||
),
|
||||
value=False,
|
||||
show=False,
|
||||
),
|
||||
@ -157,6 +160,7 @@ class SaveToFileComponent(Component):
|
||||
"The Google Drive folder ID where the file will be uploaded. "
|
||||
"The folder must be shared with the service account email."
|
||||
),
|
||||
required=True,
|
||||
show=False,
|
||||
advanced=True,
|
||||
),
|
||||
@ -196,11 +200,13 @@ class SaveToFileComponent(Component):
|
||||
if len(selected) == 1:
|
||||
location = selected[0]
|
||||
|
||||
# Show file_name and append_mode when any storage location is selected
|
||||
# Show file_name when any storage location is selected
|
||||
if "file_name" in build_config:
|
||||
build_config["file_name"]["show"] = True
|
||||
|
||||
# Show append_mode only for Local storage (not supported for cloud storage)
|
||||
if "append_mode" in build_config:
|
||||
build_config["append_mode"]["show"] = True
|
||||
build_config["append_mode"]["show"] = location == "Local"
|
||||
|
||||
if location == "Local":
|
||||
if "local_format" in build_config:
|
||||
@ -575,7 +581,9 @@ class SaveToFileComponent(Component):
|
||||
# Create temporary file
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=f".{file_format}", delete=False) as temp_file:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", suffix=f".{file_format}", delete=False
|
||||
) as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
@ -611,16 +619,57 @@ class SaveToFileComponent(Component):
|
||||
msg = "Google API client libraries are not installed. Please install them."
|
||||
raise ImportError(msg) from e
|
||||
|
||||
# Parse credentials
|
||||
try:
|
||||
credentials_dict = json.loads(self.service_account_key)
|
||||
except json.JSONDecodeError as e:
|
||||
msg = f"Invalid JSON in service account key: {e!s}"
|
||||
raise ValueError(msg) from e
|
||||
# Parse credentials with multiple fallback strategies
|
||||
credentials_dict = None
|
||||
parse_errors = []
|
||||
|
||||
# Create Google Drive service
|
||||
# Strategy 1: Parse as-is with strict=False to allow control characters
|
||||
try:
|
||||
credentials_dict = json.loads(self.service_account_key, strict=False)
|
||||
except json.JSONDecodeError as e:
|
||||
parse_errors.append(f"Standard parse: {e!s}")
|
||||
|
||||
# Strategy 2: Strip whitespace and try again
|
||||
if credentials_dict is None:
|
||||
try:
|
||||
cleaned_key = self.service_account_key.strip()
|
||||
credentials_dict = json.loads(cleaned_key, strict=False)
|
||||
except json.JSONDecodeError as e:
|
||||
parse_errors.append(f"Stripped parse: {e!s}")
|
||||
|
||||
# Strategy 3: Check if it's double-encoded (JSON string of a JSON string)
|
||||
if credentials_dict is None:
|
||||
try:
|
||||
decoded_once = json.loads(self.service_account_key, strict=False)
|
||||
if isinstance(decoded_once, str):
|
||||
credentials_dict = json.loads(decoded_once, strict=False)
|
||||
else:
|
||||
credentials_dict = decoded_once
|
||||
except json.JSONDecodeError as e:
|
||||
parse_errors.append(f"Double-encoded parse: {e!s}")
|
||||
|
||||
# Strategy 4: Try to fix common issues with newlines in the private_key field
|
||||
if credentials_dict is None:
|
||||
try:
|
||||
# Replace literal \n with actual newlines which is common in pasted JSON
|
||||
fixed_key = self.service_account_key.replace("\\n", "\n")
|
||||
credentials_dict = json.loads(fixed_key, strict=False)
|
||||
except json.JSONDecodeError as e:
|
||||
parse_errors.append(f"Newline-fixed parse: {e!s}")
|
||||
|
||||
if credentials_dict is None:
|
||||
error_details = "; ".join(parse_errors)
|
||||
msg = (
|
||||
f"Unable to parse service account key JSON. Tried multiple strategies: {error_details}. "
|
||||
"Please ensure you've copied the entire JSON content from your service account key file. "
|
||||
"The JSON should start with '{' and contain fields like 'type', 'project_id', 'private_key', etc."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Create Google Drive service with appropriate scopes
|
||||
# Use drive scope for folder access, file scope is too restrictive for folder verification
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
credentials_dict, scopes=["https://www.googleapis.com/auth/drive.file"]
|
||||
credentials_dict, scopes=["https://www.googleapis.com/auth/drive"]
|
||||
)
|
||||
drive_service = build("drive", "v3", credentials=credentials)
|
||||
|
||||
@ -634,16 +683,34 @@ class SaveToFileComponent(Component):
|
||||
|
||||
# Create temporary file
|
||||
file_path = f"{self.file_name}.{file_format}"
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=f".{file_format}", delete=False) as temp_file:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
suffix=f".{file_format}",
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Upload to Google Drive
|
||||
# Note: We skip explicit folder verification since it requires broader permissions.
|
||||
# If the folder doesn't exist or isn't accessible, the create() call will fail with a clear error.
|
||||
file_metadata = {"name": file_path, "parents": [self.folder_id]}
|
||||
media = MediaFileUpload(temp_file_path, resumable=True)
|
||||
|
||||
uploaded_file = drive_service.files().create(body=file_metadata, media_body=media, fields="id").execute()
|
||||
try:
|
||||
uploaded_file = (
|
||||
drive_service.files().create(body=file_metadata, media_body=media, fields="id").execute()
|
||||
)
|
||||
except Exception as e:
|
||||
msg = (
|
||||
f"Unable to upload file to Google Drive folder '{self.folder_id}'. "
|
||||
f"Error: {e!s}. "
|
||||
"Please ensure: 1) The folder ID is correct, 2) The folder exists, "
|
||||
"3) The service account has been granted access to this folder."
|
||||
)
|
||||
raise ValueError(msg) from e
|
||||
|
||||
file_id = uploaded_file.get("id")
|
||||
file_url = f"https://drive.google.com/file/d/{file_id}/view"
|
||||
|
||||
Reference in New Issue
Block a user