Merge branch 'main' into docs-1.7-release

This commit is contained in:
Mendon Kissling
2025-12-03 14:32:58 -05:00
4 changed files with 224 additions and 19 deletions

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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"