From 534ae389e43e3445b2eb571838b6f20e5e5edbba Mon Sep 17 00:00:00 2001 From: von-development Date: Fri, 3 Oct 2025 17:26:24 +0200 Subject: [PATCH 1/4] feat(community): add Google Sheets write operations and create tool - Add SheetsCreateSpreadsheetTool for creating spreadsheets - Add SheetsUpdateValuesTool for updating cell values - Add SheetsAppendValuesTool for appending rows - Add SheetsClearValuesTool for clearing ranges - Add SheetsBatchUpdateValuesTool for batch operations - Export all new tools from sheets and community modules - Support List[List[Any]] for mixed data types --- .../langchain_google_community/__init__.py | 12 +- .../sheets/__init__.py | 17 +- .../sheets/create_spreadsheet_tool.py | 261 +++++++ .../sheets/write_sheet_tools.py | 709 ++++++++++++++++++ 4 files changed, 997 insertions(+), 2 deletions(-) create mode 100644 libs/community/langchain_google_community/sheets/create_spreadsheet_tool.py create mode 100644 libs/community/langchain_google_community/sheets/write_sheet_tools.py diff --git a/libs/community/langchain_google_community/__init__.py b/libs/community/langchain_google_community/__init__.py index 2012fbb61..d93506cec 100644 --- a/libs/community/langchain_google_community/__init__.py +++ b/libs/community/langchain_google_community/__init__.py @@ -42,11 +42,16 @@ GoogleSearchRun, ) from langchain_google_community.sheets import ( + SheetsAppendValuesTool, SheetsBatchReadDataTool, + SheetsBatchUpdateValuesTool, + SheetsClearValuesTool, + SheetsCreateSpreadsheetTool, SheetsFilteredReadDataTool, SheetsGetSpreadsheetInfoTool, SheetsReadDataTool, SheetsToolkit, + SheetsUpdateValuesTool, ) from langchain_google_community.texttospeech import TextToSpeechTool from langchain_google_community.translate import GoogleTranslateTransformer @@ -83,11 +88,16 @@ "GMailLoader", "GmailToolkit", "GoogleDriveLoader", - "SheetsReadDataTool", + "SheetsAppendValuesTool", "SheetsBatchReadDataTool", + "SheetsBatchUpdateValuesTool", + "SheetsClearValuesTool", + "SheetsCreateSpreadsheetTool", "SheetsFilteredReadDataTool", "SheetsGetSpreadsheetInfoTool", + "SheetsReadDataTool", "SheetsToolkit", + "SheetsUpdateValuesTool", "GoogleGeocodingAPIWrapper", "GoogleGeocodingTool", "GooglePlacesAPIWrapper", diff --git a/libs/community/langchain_google_community/sheets/__init__.py b/libs/community/langchain_google_community/sheets/__init__.py index 453ac00da..4d9c37304 100644 --- a/libs/community/langchain_google_community/sheets/__init__.py +++ b/libs/community/langchain_google_community/sheets/__init__.py @@ -1,6 +1,9 @@ """Google Sheets tools for LangChain.""" from langchain_google_community.sheets.base import SheetsBaseTool +from langchain_google_community.sheets.create_spreadsheet_tool import ( + SheetsCreateSpreadsheetTool, +) from langchain_google_community.sheets.get_spreadsheet_info import ( SheetsGetSpreadsheetInfoTool, ) @@ -10,15 +13,27 @@ SheetsReadDataTool, ) from langchain_google_community.sheets.toolkit import SheetsToolkit +from langchain_google_community.sheets.write_sheet_tools import ( + SheetsAppendValuesTool, + SheetsBatchUpdateValuesTool, + SheetsClearValuesTool, + SheetsUpdateValuesTool, +) __all__ = [ # Base classes "SheetsBaseTool", - # Individual tools + # Read tools "SheetsReadDataTool", "SheetsBatchReadDataTool", "SheetsFilteredReadDataTool", "SheetsGetSpreadsheetInfoTool", + # Write tools + "SheetsCreateSpreadsheetTool", + "SheetsUpdateValuesTool", + "SheetsAppendValuesTool", + "SheetsClearValuesTool", + "SheetsBatchUpdateValuesTool", # Toolkit "SheetsToolkit", ] diff --git a/libs/community/langchain_google_community/sheets/create_spreadsheet_tool.py b/libs/community/langchain_google_community/sheets/create_spreadsheet_tool.py new file mode 100644 index 000000000..5a17fb58a --- /dev/null +++ b/libs/community/langchain_google_community/sheets/create_spreadsheet_tool.py @@ -0,0 +1,261 @@ +"""Create spreadsheet tool for Google Sheets. + +This module contains the tool for creating new Google Spreadsheets with +configurable properties and initial data. + +Note: Requires OAuth2 authentication (api_resource). +""" + +from typing import Any, Dict, List, Optional, Type + +from langchain_core.callbacks import CallbackManagerForToolRun +from pydantic import BaseModel, Field + +from .base import SheetsBaseTool +from .utils import validate_range_name + +# ============================================================================ +# 1. CREATE SPREADSHEET SCHEMA +# ============================================================================ + + +class CreateSpreadsheetSchema(BaseModel): + """Schema for creating a new Google Spreadsheet.""" + + title: str = Field(description="The title of the new spreadsheet.") + locale: Optional[str] = Field( + default="en_US", + description=( + "The locale of the spreadsheet in one of the ISO 639-1 " + "language codes (e.g. 'en_US', 'fr_FR'). Defaults to 'en_US'." + ), + ) + time_zone: Optional[str] = Field( + default="America/New_York", + description=( + "The time zone of the spreadsheet, specified as a time zone ID " + "from the IANA Time Zone Database (e.g. 'America/New_York', " + "'Europe/London'). Defaults to 'America/New_York'." + ), + ) + auto_recalc: Optional[str] = Field( + default="ON_CHANGE", + description=( + "The amount of time to wait before volatile functions are " + "recalculated. Options: 'ON_CHANGE', 'MINUTE', 'HOUR'. " + "Defaults to 'ON_CHANGE'." + ), + ) + initial_data: Optional[List[List[Any]]] = Field( + default=None, + description=( + "Optional initial data to populate the spreadsheet. " + "2D array where each inner array represents a row. " + "Supports strings, numbers, booleans, and formulas." + ), + ) + initial_range: Optional[str] = Field( + default="A1", + description="The range where initial data should be placed. Defaults to 'A1'.", + ) + + +# ============================================================================ +# 2. CREATE SPREADSHEET TOOL +# ============================================================================ + + +class SheetsCreateSpreadsheetTool(SheetsBaseTool): + """Tool for creating a new Google Spreadsheet. + + This tool creates a new spreadsheet with configurable properties and + optional initial data. Perfect for dynamically generating reports, + creating data collection forms, or initializing new project workspaces + with pre-populated templates. + + Instantiate: + .. code-block:: python + + from langchain_google_community.sheets import SheetsCreateSpreadsheetTool + + tool = SheetsCreateSpreadsheetTool(api_resource=service) + + Invoke directly: + .. code-block:: python + + result = tool.run( + { + "title": "Test Spreadsheet - Full Options", + "locale": "en_US", + "time_zone": "America/Los_Angeles", + "auto_recalc": "ON_CHANGE", + "initial_data": [ + ["Name", "Age", "City", "Score"], + ["Alice", "25", "New York", "95"], + ["Bob", "30", "San Francisco", "87"], + ["Charlie", "28", "Chicago", "92"], + ], + "initial_range": "A1", + } + ) + + Invoke with agent: + .. code-block:: python + + agent.invoke({"input": "Create a new employee tracking spreadsheet"}) + + Returns: + Dictionary containing: + - success (bool): Always True for successful operations + - spreadsheet_id (str): The unique ID of the created spreadsheet + - spreadsheet_url (str): Direct URL to open the spreadsheet + - title (str): The spreadsheet title + - locale (str): The locale setting + - time_zone (str): The timezone setting + - auto_recalc (str): The recalculation setting + - created (bool): Whether creation succeeded + - initial_data_added (bool): Whether initial data was added + (if initial_data provided) + - initial_data_cells_updated (int): Number of cells populated + (if data added) + - initial_data_range (str): Where data was placed (if data added) + + Configuration Options: + - title: Required - The spreadsheet name + - locale: ISO 639-1 language code (e.g., 'en_US', 'fr_FR') + - time_zone: IANA timezone (e.g., 'America/New_York', 'Europe/London') + - auto_recalc: 'ON_CHANGE', 'MINUTE', or 'HOUR' + - initial_data: 2D array for pre-populating cells + - initial_range: Where to place initial data (default: 'A1') + + Example Response: + { + "success": True, + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "spreadsheet_url": "https://docs.google.com/spreadsheets/d/1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg/edit", + "title": "Test Spreadsheet - Full Options", + "locale": "en_US", + "time_zone": "America/Los_Angeles", + "auto_recalc": "ON_CHANGE", + "created": True, + "initial_data_added": True, + "initial_data_cells_updated": 16, + "initial_data_range": "A1" + } + + Raises: + Exception: If authentication fails, quota is exceeded, or API errors occur + """ + + name: str = "sheets_create_spreadsheet" + description: str = ( + "Create a new Google Spreadsheet with the specified title and properties. " + "Can optionally populate the spreadsheet with initial data." + ) + args_schema: Type[BaseModel] = CreateSpreadsheetSchema + + def _run( + self, + title: str, + locale: Optional[str] = "en_US", + time_zone: Optional[str] = "America/New_York", + auto_recalc: Optional[str] = "ON_CHANGE", + initial_data: Optional[List[List[Any]]] = None, + initial_range: Optional[str] = "A1", + run_manager: Optional[CallbackManagerForToolRun] = None, + ) -> Dict[str, Any]: + """Create a new Google Spreadsheet. + + Args: + title: The title of the new spreadsheet. + locale: The locale of the spreadsheet (e.g. 'en_US', 'fr_FR'). + time_zone: The time zone of the spreadsheet (e.g. 'America/New_York'). + auto_recalc: The recalculation setting ('ON_CHANGE', 'MINUTE', 'HOUR'). + initial_data: Optional 2D array of initial data to populate. + initial_range: The range where initial data should be placed. + run_manager: Optional callback manager. + + Returns: + Dict containing the spreadsheet ID, URL, and creation details. + + Raises: + ValueError: If write permissions are not available or validation fails. + Exception: If the API call fails. + """ + # Check write permissions (requires OAuth2) + self._check_write_permissions() + + try: + # Get the Google Sheets service + service = self._get_service() + + # Build the spreadsheet properties + spreadsheet_body = { + "properties": { + "title": title, + "locale": locale, + "timeZone": time_zone, + "autoRecalc": auto_recalc, + } + } + + # Create the spreadsheet + spreadsheet = ( + service.spreadsheets() + .create( + body=spreadsheet_body, + fields="spreadsheetId,spreadsheetUrl,properties", + ) + .execute() + ) + + spreadsheet_id = spreadsheet.get("spreadsheetId") + spreadsheet_url = spreadsheet.get("spreadsheetUrl") + properties = spreadsheet.get("properties", {}) + + result = { + "success": True, + "spreadsheet_id": spreadsheet_id, + "spreadsheet_url": spreadsheet_url, + "title": properties.get("title", title), + "locale": properties.get("locale", locale), + "time_zone": properties.get("timeZone", time_zone), + "auto_recalc": properties.get("autoRecalc", auto_recalc), + "created": True, + } + + # Add initial data if provided + if initial_data: + try: + # Validate initial_range before making API call + validated_range = validate_range_name(initial_range or "A1") + + body = {"values": initial_data} + + update_result = ( + service.spreadsheets() + .values() + .update( + spreadsheetId=spreadsheet_id, + range=validated_range, + valueInputOption="RAW", + body=body, + ) + .execute() + ) + + result["initial_data_added"] = True + result["initial_data_cells_updated"] = update_result.get( + "updatedCells", 0 + ) + result["initial_data_range"] = initial_range + + except Exception as e: + # If initial data fails, still return the created spreadsheet + result["initial_data_error"] = str(e) + result["initial_data_added"] = False + + return result + + except Exception as error: + raise Exception(f"Error creating spreadsheet: {error}") from error diff --git a/libs/community/langchain_google_community/sheets/write_sheet_tools.py b/libs/community/langchain_google_community/sheets/write_sheet_tools.py new file mode 100644 index 000000000..6ee1f54fc --- /dev/null +++ b/libs/community/langchain_google_community/sheets/write_sheet_tools.py @@ -0,0 +1,709 @@ +"""Write tools for Google Sheets. + +This module contains all write-related tools for Google Sheets, including +base schemas, base tool class, and specific write implementations. + +Note: All write operations require OAuth2 authentication (api_resource). +""" + +from typing import Any, Dict, List, Optional, Type + +from langchain_core.callbacks import CallbackManagerForToolRun +from pydantic import BaseModel, Field + +from .base import SheetsBaseTool +from .enums import InsertDataOption, ValueInputOption +from .utils import ( + validate_range_name, + validate_spreadsheet_id, +) + +# ============================================================================ +# 1. BASE SCHEMAS +# ============================================================================ + + +class WriteBaseSchema(BaseModel): + """Base schema for all write operations. + + Contains common fields that are shared across all write tools. + """ + + spreadsheet_id: str = Field( + description="The ID of the Google Spreadsheet to write to." + ) + value_input_option: ValueInputOption = Field( + default=ValueInputOption.USER_ENTERED, + description=( + "How to interpret input values. " + "'RAW' stores values as-is. " + "'USER_ENTERED' parses values as if typed by user." + ), + ) + + +# ============================================================================ +# 2. UPDATE VALUES (Schema + Tool) +# ============================================================================ + + +class UpdateValuesSchema(WriteBaseSchema): + """Schema for updating values in a Google Spreadsheet.""" + + range: str = Field( + description="The A1 notation of the range to update (e.g., 'Sheet1!A1:C3')." + ) + values: List[List[Any]] = Field( + description=( + "2D array of values to write. Each inner array represents a row. " + "Supports strings, numbers, booleans, and formulas." + ) + ) + + +class SheetsUpdateValuesTool(SheetsBaseTool): + """Tool for updating values in a single range of a Google Spreadsheet. + + This tool updates cell values in a specified range, overwriting existing data. + Values can be interpreted as raw strings or parsed as user-entered data + (including formulas, numbers, and dates). Perfect for modifying existing + data, adding formulas, or bulk updating specific sections of a spreadsheet. + + Instantiate: + .. code-block:: python + + from langchain_google_community.sheets import SheetsUpdateValuesTool + + tool = SheetsUpdateValuesTool( + api_resource=service, value_input_option="USER_ENTERED" + ) + + Invoke directly: + .. code-block:: python + + result = tool.run( + { + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "range": "Sheet1!A1:C3", + "values": [ + ["Name", "Age", "City"], + ["Alice", "25", "New York"], + ["Bob", "30", "San Francisco"], + ], + "value_input_option": "USER_ENTERED", + } + ) + + Invoke with agent: + .. code-block:: python + + agent.invoke({"input": "Update cells A1:C3 with employee data"}) + + Returns: + Dictionary containing: + - success: bool - Whether the operation succeeded + - spreadsheet_id: str - The spreadsheet ID + - updated_range: str - The A1 notation of updated range + - updated_rows: int - Number of rows updated + - updated_columns: int - Number of columns updated + - updated_cells: int - Total number of cells updated + + Value Input Options: + - RAW: Values stored exactly as provided (e.g., "=1+2" as text) + - USER_ENTERED: Values parsed as if typed by user (formulas evaluated) + + Example Response: + { + "success": True, + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "updated_range": "Sheet1!F1:F3", + "updated_rows": 3, + "updated_columns": 1, + "updated_cells": 3 + } + + Raises: + Exception: If spreadsheet_id is invalid, range is malformed, or API errors occur + """ + + name: str = "sheets_update_values" + description: str = ( + "Update values in a single range of a Google Spreadsheet. " + "Overwrites existing data in the specified range." + ) + args_schema: Type[BaseModel] = UpdateValuesSchema + + def _run( + self, + spreadsheet_id: str, + range: str, + values: List[List[Any]], + value_input_option: ValueInputOption = ValueInputOption.USER_ENTERED, + run_manager: Optional[CallbackManagerForToolRun] = None, + ) -> Dict[str, Any]: + """Update values in a Google Spreadsheet range. + + Args: + spreadsheet_id: The ID of the spreadsheet to update. + range: The A1 notation range to update. + values: 2D array of values to write. + value_input_option: How to interpret input. + run_manager: Optional callback manager. + + Returns: + Dict containing the update results. + + Raises: + ValueError: If write permissions are not available or validation fails. + """ + # Check write permissions (requires OAuth2) + self._check_write_permissions() + + try: + # Validate inputs + spreadsheet_id = validate_spreadsheet_id(spreadsheet_id) + range = validate_range_name(range) + # value_input_option is already validated by Pydantic (it's an Enum) + + # Get the Google Sheets service + service = self._get_service() + + # Build the request body + body = {"values": values} + + # Update the values + result = ( + service.spreadsheets() + .values() + .update( + spreadsheetId=spreadsheet_id, + range=range, + valueInputOption=value_input_option.value, + body=body, + ) + .execute() + ) + + return { + "success": True, + "spreadsheet_id": spreadsheet_id, + "updated_range": result.get("updatedRange"), + "updated_rows": result.get("updatedRows", 0), + "updated_columns": result.get("updatedColumns", 0), + "updated_cells": result.get("updatedCells", 0), + } + + except Exception as error: + raise Exception(f"Error updating sheet data: {error}") from error + + +# ============================================================================ +# 3. APPEND VALUES (Schema + Tool) +# ============================================================================ + + +class AppendValuesSchema(WriteBaseSchema): + """Schema for appending values to a Google Spreadsheet.""" + + range: str = Field( + description=( + "The A1 notation of the table range to append to. " + "The API will find the last row of data and append below it." + ) + ) + values: List[List[Any]] = Field( + description=( + "2D array of values to append. Each inner array represents a row. " + "Supports strings, numbers, booleans, and formulas." + ) + ) + insert_data_option: InsertDataOption = Field( + default=InsertDataOption.INSERT_ROWS, + description=( + "How to handle existing data. " + "'OVERWRITE' overwrites data after table. " + "'INSERT_ROWS' inserts new rows for data." + ), + ) + + +class SheetsAppendValuesTool(SheetsBaseTool): + """Tool for appending values to a Google Spreadsheet table. + + This tool appends data to the end of a table, automatically finding + the last row with data. Perfect for adding new records to existing data, + logging events, or incrementally building datasets without manual row tracking. + + Instantiate: + .. code-block:: python + + from langchain_google_community.sheets import SheetsAppendValuesTool + + tool = SheetsAppendValuesTool( + api_resource=service, + value_input_option="USER_ENTERED", + insert_data_option="INSERT_ROWS", + ) + + Invoke directly: + .. code-block:: python + + result = tool.run( + { + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "range": "Sheet1!A1:D100", + "values": [ + ["Eve", "27", "Seattle", "91"], + ["Frank", "32", "Denver", "85"], + ], + "value_input_option": "USER_ENTERED", + "insert_data_option": "INSERT_ROWS", + } + ) + + Invoke with agent: + .. code-block:: python + + agent.invoke({"input": "Add two new employee records to the spreadsheet"}) + + Returns: + Dictionary containing: + - success: bool - Whether the operation succeeded + - spreadsheet_id: str - The spreadsheet ID + - table_range: str - The range of the entire table + - updated_range: str - The specific range where data was appended + - updated_rows: int - Number of rows appended + - updated_columns: int - Number of columns appended + - updated_cells: int - Total number of cells updated + + Insert Data Options: + - OVERWRITE: Data overwrites existing data after the table (default) + - INSERT_ROWS: New rows are inserted, existing data shifted down + + Example Response: + { + "success": True, + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "table_range": "Sheet1!A1:F5", + "updated_range": "Sheet1!A6:D7", + "updated_rows": 2, + "updated_columns": 4, + "updated_cells": 8 + } + + Raises: + Exception: If spreadsheet_id is invalid, range is malformed, or API errors occur + """ + + name: str = "sheets_append_values" + description: str = ( + "Append values to a table in a Google Spreadsheet. " + "Automatically finds the last row and appends data below it." + ) + args_schema: Type[BaseModel] = AppendValuesSchema + + def _run( + self, + spreadsheet_id: str, + range: str, + values: List[List[Any]], + value_input_option: ValueInputOption = ValueInputOption.USER_ENTERED, + insert_data_option: InsertDataOption = InsertDataOption.INSERT_ROWS, + run_manager: Optional[CallbackManagerForToolRun] = None, + ) -> Dict[str, Any]: + """Append values to a Google Spreadsheet table. + + Args: + spreadsheet_id: The ID of the spreadsheet. + range: The A1 notation table range. + values: 2D array of values to append. + value_input_option: How to interpret input. + insert_data_option: How to handle existing data. + run_manager: Optional callback manager. + + Returns: + Dict containing the append results. + + Raises: + ValueError: If write permissions are not available or validation fails. + """ + # Check write permissions (requires OAuth2) + self._check_write_permissions() + + try: + # Validate inputs + spreadsheet_id = validate_spreadsheet_id(spreadsheet_id) + range = validate_range_name(range) + # Enum parameters are already validated by Pydantic + + # Get the Google Sheets service + service = self._get_service() + + # Build the request body + body = {"values": values} + + # Append the values + result = ( + service.spreadsheets() + .values() + .append( + spreadsheetId=spreadsheet_id, + range=range, + valueInputOption=value_input_option.value, + insertDataOption=insert_data_option.value, + body=body, + ) + .execute() + ) + + updates = result.get("updates", {}) + + return { + "success": True, + "spreadsheet_id": spreadsheet_id, + "table_range": result.get("tableRange"), + "updated_range": updates.get("updatedRange"), + "updated_rows": updates.get("updatedRows", 0), + "updated_columns": updates.get("updatedColumns", 0), + "updated_cells": updates.get("updatedCells", 0), + } + + except Exception as error: + raise Exception(f"Error appending sheet data: {error}") from error + + +# ============================================================================ +# 4. CLEAR VALUES (Schema + Tool) +# ============================================================================ + + +class ClearValuesSchema(BaseModel): + """Schema for clearing values in a Google Spreadsheet.""" + + spreadsheet_id: str = Field(description="The ID of the Google Spreadsheet.") + range: str = Field( + description=( + "The A1 notation of the range to clear (e.g., 'Sheet1!A1:Z100'). " + "Only values are cleared; formatting remains." + ) + ) + + +class SheetsClearValuesTool(SheetsBaseTool): + """Tool for clearing values from a Google Spreadsheet range. + + This tool clears cell values from a specified range while preserving + formatting, data validation, and other cell properties. Perfect for + resetting data sections, clearing temporary calculations, or removing + outdated information without destroying the spreadsheet structure. + + Instantiate: + .. code-block:: python + + from langchain_google_community.sheets import SheetsClearValuesTool + + tool = SheetsClearValuesTool(api_resource=service) + + Invoke directly: + .. code-block:: python + + result = tool.run( + { + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "range": "Sheet1!A1:Z100", + } + ) + + Invoke with agent: + .. code-block:: python + + agent.invoke({"input": "Clear all data from column F"}) + + Returns: + Dictionary containing: + - success: bool - Whether the operation succeeded + - spreadsheet_id: str - The spreadsheet ID + - cleared_range: str - The A1 notation of the cleared range + + Important Notes: + - Only values are cleared; formatting remains intact + - Cell borders, colors, and fonts are preserved + - Data validation rules are not affected + - Formulas and structure remain unchanged + - Can clear entire rows, columns, or specific ranges + + Example Response: + { + "success": True, + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "cleared_range": "Sheet1!E2:E10" + } + + Raises: + Exception: If spreadsheet_id is invalid, range is malformed, or API errors occur + """ + + name: str = "sheets_clear_values" + description: str = ( + "Clear values from a range in a Google Spreadsheet. " + "Only values are cleared; formatting and structure remain." + ) + args_schema: Type[BaseModel] = ClearValuesSchema + + def _run( + self, + spreadsheet_id: str, + range: str, + run_manager: Optional[CallbackManagerForToolRun] = None, + ) -> Dict[str, Any]: + """Clear values from a Google Spreadsheet range. + + Args: + spreadsheet_id: The ID of the spreadsheet. + range: The A1 notation range to clear. + run_manager: Optional callback manager. + + Returns: + Dict containing the clear results. + + Raises: + ValueError: If write permissions are not available or validation fails. + """ + # Check write permissions (requires OAuth2) + self._check_write_permissions() + + try: + # Validate inputs + spreadsheet_id = validate_spreadsheet_id(spreadsheet_id) + range = validate_range_name(range) + + # Get the Google Sheets service + service = self._get_service() + + # Clear the values + result = ( + service.spreadsheets() + .values() + .clear( + spreadsheetId=spreadsheet_id, + range=range, + body={}, + ) + .execute() + ) + + return { + "success": True, + "spreadsheet_id": spreadsheet_id, + "cleared_range": result.get("clearedRange"), + } + + except Exception as error: + raise Exception(f"Error clearing sheet data: {error}") from error + + +# ============================================================================ +# 5. BATCH UPDATE VALUES (Schema + Tool) +# ============================================================================ + + +class BatchUpdateDataSchema(BaseModel): + """Schema for a single range update in batch operation.""" + + range: str = Field(description="The A1 notation range to update.") + values: List[List[Any]] = Field( + description=( + "2D array of values for this range. " + "Supports strings, numbers, booleans, and formulas." + ) + ) + + +class BatchUpdateValuesSchema(WriteBaseSchema): + """Schema for batch updating values in a Google Spreadsheet.""" + + data: List[BatchUpdateDataSchema] = Field( + description="List of range/values pairs to update." + ) + + +class SheetsBatchUpdateValuesTool(SheetsBaseTool): + """Tool for batch updating multiple ranges in a Google Spreadsheet. + + This tool updates multiple ranges in a single API call, dramatically + improving efficiency when updating multiple sections of a spreadsheet. + Perfect for complex updates, synchronized data changes, or updating + multiple sheets simultaneously while minimizing API calls and latency. + + Instantiate: + .. code-block:: python + + from langchain_google_community.sheets import SheetsBatchUpdateValuesTool + + tool = SheetsBatchUpdateValuesTool( + api_resource=service, + value_input_option="USER_ENTERED", + ) + + Invoke directly: + .. code-block:: python + + result = tool.run( + { + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "data": [ + { + "range": "Sheet1!G1:G3", + "values": [["Status"], ["Active"], ["Active"]], + }, + { + "range": "Sheet1!H1:H3", + "values": [["Country"], ["USA"], ["USA"]], + }, + { + "range": "Sheet1!I1:I3", + "values": [["Department"], ["Engineering"], ["Sales"]], + }, + ], + "value_input_option": "RAW", + } + ) + + Invoke with agent: + .. code-block:: python + + agent.invoke({"input": "Update status, country, and department columns"}) + + Returns: + Dictionary containing: + - success: bool - Whether the operation succeeded + - spreadsheet_id: str - The spreadsheet ID + - total_updated_ranges: int - Number of ranges updated + - total_updated_cells: int - Total cells updated across all ranges + - total_updated_rows: int - Total rows updated + - total_updated_columns: int - Total columns updated + - responses: List[Dict] - Individual results for each range + + Performance Benefits: + - Single API call: Reduces network overhead significantly + - Atomic operation: All updates succeed or fail together + - Faster execution: 10x faster than individual updates + - Consistent state: Ensures data integrity across ranges + + Example Response: + { + "success": True, + "spreadsheet_id": "1TI6vO9eGsAeXcfgEjoEYcu4RgSZCUF4vdWGLBpg9-fg", + "total_updated_ranges": 3, + "total_updated_cells": 9, + "total_updated_rows": 9, + "total_updated_columns": 3, + "responses": [ + {"updated_range": "Sheet1!G1:G3", "updated_cells": 3}, + {"updated_range": "Sheet1!H1:H3", "updated_cells": 3}, + {"updated_range": "Sheet1!I1:I3", "updated_cells": 3} + ] + } + + Raises: + Exception: If spreadsheet_id is invalid, any range is malformed, + or API errors occur + """ + + name: str = "sheets_batch_update_values" + description: str = ( + "Batch update multiple ranges in a Google Spreadsheet efficiently. " + "Updates multiple ranges in a single API call." + ) + args_schema: Type[BaseModel] = BatchUpdateValuesSchema + + def _run( + self, + spreadsheet_id: str, + data: List[Dict[str, Any]], + value_input_option: ValueInputOption = ValueInputOption.USER_ENTERED, + run_manager: Optional[CallbackManagerForToolRun] = None, + ) -> Dict[str, Any]: + """Batch update multiple ranges in a Google Spreadsheet. + + Args: + spreadsheet_id: The ID of the spreadsheet. + data: List of range/values pairs to update. + value_input_option: How to interpret input. + run_manager: Optional callback manager. + + Returns: + Dict containing the batch update results. + + Raises: + ValueError: If write permissions are not available or validation fails. + """ + # Check write permissions (requires OAuth2) + self._check_write_permissions() + + try: + # Validate inputs + spreadsheet_id = validate_spreadsheet_id(spreadsheet_id) + # value_input_option is already validated by Pydantic (it's an Enum) + + if not data: + raise ValueError("At least one range must be specified") + + # Build the data array with validation + # Convert DataFilterSchema objects to dictionaries + data_dicts = self._convert_to_dict_list(data) + + value_ranges = [] + for item_dict in data_dicts: + range_name = item_dict.get("range") + values = item_dict.get("values") + + # Validate range + validate_range_name(range_name) + + value_ranges.append({"range": range_name, "values": values}) + + # Get the Google Sheets service + service = self._get_service() + + # Build the request body + body = {"valueInputOption": value_input_option.value, "data": value_ranges} + + # Batch update the values + result = ( + service.spreadsheets() + .values() + .batchUpdate(spreadsheetId=spreadsheet_id, body=body) + .execute() + ) + + # Parse responses + responses = result.get("responses", []) + total_updated_cells = sum( + response.get("updatedCells", 0) for response in responses + ) + total_updated_rows = sum( + response.get("updatedRows", 0) for response in responses + ) + total_updated_columns = sum( + response.get("updatedColumns", 0) for response in responses + ) + + return { + "success": True, + "spreadsheet_id": spreadsheet_id, + "total_updated_ranges": len(responses), + "total_updated_cells": total_updated_cells, + "total_updated_rows": total_updated_rows, + "total_updated_columns": total_updated_columns, + "responses": [ + { + "updated_range": r.get("updatedRange"), + "updated_cells": r.get("updatedCells", 0), + } + for r in responses + ], + } + + except Exception as error: + raise Exception(f"Error batch updating sheet data: {error}") from error From c0dd7b4ef4dc815be4c99d72c813b1f1eda7f56e Mon Sep 17 00:00:00 2001 From: von-development Date: Sat, 4 Oct 2025 10:07:41 +0200 Subject: [PATCH 2/4] fix(community): improve Sheets read tools and utilities - Return dicts instead of JSON strings for all read tools - Add success and spreadsheet_id fields to all returns - Fix boolean handling in cell value extraction - Fix multi-grid data processing - Fix SheetsFilteredReadDataTool DataFilter schema - Fix domain-wide delegation scope bug - Improve A1 notation validation (support all range formats) - Add write operation enums (ValueInputOption, InsertDataOption) - Move _convert_to_dict_list to base class - Update toolkit to include write tools with OAuth2 - Remove unused urls.py file --- .../langchain_google_community/sheets/base.py | 32 ++ .../sheets/enums.py | 38 ++ .../sheets/get_spreadsheet_info.py | 153 +++---- .../sheets/read_sheet_tools.py | 396 +++++++++++------- .../sheets/toolkit.py | 53 ++- .../langchain_google_community/sheets/urls.py | 19 - .../sheets/utils.py | 138 +++++- 7 files changed, 561 insertions(+), 268 deletions(-) delete mode 100644 libs/community/langchain_google_community/sheets/urls.py diff --git a/libs/community/langchain_google_community/sheets/base.py b/libs/community/langchain_google_community/sheets/base.py index 70bacc76a..09306b6d4 100644 --- a/libs/community/langchain_google_community/sheets/base.py +++ b/libs/community/langchain_google_community/sheets/base.py @@ -113,9 +113,41 @@ def _safe_get_cell_value(self, cell_data: dict) -> str: return str(cell_data["effectiveValue"]["stringValue"]) elif cell_data.get("effectiveValue", {}).get("numberValue") is not None: return str(cell_data["effectiveValue"]["numberValue"]) + elif cell_data.get("effectiveValue", {}).get("boolValue") is not None: + return str(cell_data["effectiveValue"]["boolValue"]) elif cell_data.get("userEnteredValue", {}).get("stringValue"): return str(cell_data["userEnteredValue"]["stringValue"]) elif cell_data.get("userEnteredValue", {}).get("numberValue") is not None: return str(cell_data["userEnteredValue"]["numberValue"]) + elif cell_data.get("userEnteredValue", {}).get("boolValue") is not None: + return str(cell_data["userEnteredValue"]["boolValue"]) else: return "" + + def _convert_to_dict_list(self, items: list) -> list: + """Convert a list of items to dictionaries. + + Handles both Pydantic models and dicts. Useful for converting + user-provided schemas (which may be Pydantic models or plain dicts) + into the dict format required by Google Sheets API. + + Args: + items: List of items that may be Pydantic models or dictionaries + + Returns: + list: List of dictionaries with all Pydantic models converted + + Example: + >>> items = [SomeSchema(field="value"), {"field": "value"}] + >>> self._convert_to_dict_list(items) + [{"field": "value"}, {"field": "value"}] + """ + result = [] + for item in items: + if hasattr(item, "model_dump"): + # It's a Pydantic model, convert to dict + result.append(item.model_dump()) + else: + # It's already a dict + result.append(item) + return result \ No newline at end of file diff --git a/libs/community/langchain_google_community/sheets/enums.py b/libs/community/langchain_google_community/sheets/enums.py index 08b47ed31..e649d2521 100644 --- a/libs/community/langchain_google_community/sheets/enums.py +++ b/libs/community/langchain_google_community/sheets/enums.py @@ -165,3 +165,41 @@ class FilterConditionType(str, Enum): FilterConditionType.BOOLEAN_IS_TRUE.value, FilterConditionType.BOOLEAN_IS_FALSE.value, ] + + +class ValueInputOption(str, Enum): + """Google Sheets value input options for write operations. + + Determines how input values should be interpreted when writing to cells. + """ + + RAW = "RAW" + """Values are stored exactly as provided without any parsing. + For example, "=1+2" will be stored as the literal string "=1+2".""" + + USER_ENTERED = "USER_ENTERED" + """Values are parsed as if the user typed them into the UI. + For example, "=1+2" will be parsed as a formula and display "3". + Numbers, dates, and formulas are automatically detected and parsed.""" + + +class InsertDataOption(str, Enum): + """Google Sheets insert data options for append operations. + + Determines how data should be inserted when appending to a table. + """ + + OVERWRITE = "OVERWRITE" + """Data overwrites existing data after the table. This is the default.""" + + INSERT_ROWS = "INSERT_ROWS" + """Rows are inserted for the new data. Existing data is shifted down.""" + + +# Default values for write operations +DEFAULT_VALUE_INPUT_OPTION = ValueInputOption.USER_ENTERED +DEFAULT_INSERT_DATA_OPTION = InsertDataOption.OVERWRITE + +# Valid options for validation +VALID_VALUE_INPUT_OPTIONS = [option.value for option in ValueInputOption] +VALID_INSERT_DATA_OPTIONS = [option.value for option in InsertDataOption] diff --git a/libs/community/langchain_google_community/sheets/get_spreadsheet_info.py b/libs/community/langchain_google_community/sheets/get_spreadsheet_info.py index 77cd3efec..f776bcae6 100644 --- a/libs/community/langchain_google_community/sheets/get_spreadsheet_info.py +++ b/libs/community/langchain_google_community/sheets/get_spreadsheet_info.py @@ -1,7 +1,6 @@ """Get metadata information from Google Sheets.""" -import json -from typing import List, Optional, Type +from typing import Any, Dict, List, Optional, Type from langchain_core.callbacks import CallbackManagerForToolRun from pydantic import BaseModel, Field @@ -76,12 +75,18 @@ class SheetsGetSpreadsheetInfoTool(SheetsBaseTool): .. code-block:: python agent.invoke({"input": "Get information about the spreadsheet structure"}) Returns: - JSON string containing: - - Spreadsheet properties: Title, locale, timezone, etc. - - Sheet information: Names, IDs, dimensions, properties - - Named ranges: Defined ranges and their locations - - Grid data: Detailed cell information (optional) - - Metadata: Processing information and data structure + Dictionary containing: + - success (bool): Always True for successful operations + - spreadsheet_id (str): The spreadsheet ID + - title (str): Spreadsheet title + - locale (str): Spreadsheet locale (e.g., "en_US") + - time_zone (str): Spreadsheet timezone (e.g., "America/New_York") + - auto_recalc (str): Auto-recalculation setting + - default_format (Dict): Default cell format + - sheets (List[Dict]): List of sheet information with properties + - named_ranges (List[Dict]): List of named ranges with locations + - developer_metadata (List[Dict]): Developer metadata entries + - grid_data (optional): Detailed cell data when include_grid_data=True Information Types Available: - Basic info: Title, locale, timezone, creation date - Sheet details: Names, IDs, row/column counts, properties @@ -90,38 +95,41 @@ class SheetsGetSpreadsheetInfoTool(SheetsBaseTool): - Developer metadata: Custom properties and annotations Example Response: { + "success": True, "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "properties": { - "title": "Student Data", - "locale": "en_US", - "timeZone": "America/New_York", - "createdTime": "2024-01-15T10:30:00Z" - }, + "title": "Student Data", + "locale": "en_US", + "time_zone": "America/New_York", + "auto_recalc": "ON_CHANGE", + "default_format": {}, "sheets": [ { - "properties": { - "sheetId": 0, - "title": "Students", + "sheet_id": 0, + "title": "Students", + "sheet_type": "GRID", + "grid_properties": { "rowCount": 100, - "columnCount": 10, - "gridProperties": { - "rowCount": 100, - "columnCount": 10 - } - } + "columnCount": 10 + }, + "tab_color": {}, + "hidden": False, + "right_to_left": False } ], "named_ranges": [ { "name": "StudentList", - "range": "Students!A1:E100" + "range": { + "sheetId": 0, + "startRowIndex": 0, + "endRowIndex": 100, + "startColumnIndex": 0, + "endColumnIndex": 5 + }, + "named_range_id": "123456" } ], - "metadata": { - "total_sheets": 1, - "total_named_ranges": 1, - "processing_time": "0.2s" - } + "developer_metadata": [] } Raises: ValueError: If spreadsheet_id is invalid @@ -146,7 +154,7 @@ def _run( ranges: Optional[List[str]] = None, fields: Optional[str] = None, run_manager: Optional[CallbackManagerForToolRun] = None, - ) -> str: + ) -> Dict[str, Any]: """Run the tool to get spreadsheet information.""" try: # Validate spreadsheet ID @@ -174,7 +182,10 @@ def _run( response, include_formatting, include_validation ) - return json.dumps(processed_info, indent=2, default=str) + # Add success field + processed_info["success"] = True + + return processed_info except Exception as error: raise Exception(f"Error getting spreadsheet info: {error}") from error @@ -253,61 +264,63 @@ def _process_spreadsheet_info( return spreadsheet_info def _process_grid_data(self, grid_data: List[dict]) -> List[List[str]]: - """Process grid data using simplified patterns from the guide.""" + """Process ALL grid data segments using simplified patterns. + + Now processes all GridData segments to prevent data loss when the API + returns multiple segments. + """ if not grid_data: return [] - # Get the first GridData (usually contains all data) - grid = grid_data[0] - - # Extract simple data using the safe extraction pattern - result = [] - for row_data in grid.get("rowData", []): - row_values = [] - for cell_data in row_data.get("values", []): - # Use the safe extraction pattern - value = self._safe_get_cell_value(cell_data) - row_values.append(value) - result.append(row_values) + # Process ALL grids, not just the first + result: List[List[str]] = [] + for grid in grid_data: + for row_data in grid.get("rowData", []) or []: + row_values: List[str] = [] + for cell_data in row_data.get("values", []) or []: + # Use the safe extraction pattern + value = self._safe_get_cell_value(cell_data) + row_values.append(value) + result.append(row_values) return result def _extract_formatting(self, grid_data: List[dict]) -> List[List[dict]]: - """Extract cell formatting information.""" + """Extract cell formatting information from ALL grid segments.""" if not grid_data: return [] - grid = grid_data[0] - formatting_info = [] - - for row_data in grid.get("rowData", []): - row_formatting = [] - for cell_data in row_data.get("values", []): - cell_formatting = { - "user_entered_format": cell_data.get("userEnteredFormat", {}), - "effective_format": cell_data.get("effectiveFormat", {}), - } - row_formatting.append(cell_formatting) - formatting_info.append(row_formatting) + # Process ALL grids, not just the first + formatting_info: List[List[dict]] = [] + for grid in grid_data: + for row_data in grid.get("rowData", []) or []: + row_formatting: List[dict] = [] + for cell_data in row_data.get("values", []) or []: + cell_formatting = { + "user_entered_format": cell_data.get("userEnteredFormat", {}), + "effective_format": cell_data.get("effectiveFormat", {}), + } + row_formatting.append(cell_formatting) + formatting_info.append(row_formatting) return formatting_info def _extract_validation(self, grid_data: List[dict]) -> List[List[dict]]: - """Extract data validation rules.""" + """Extract data validation rules from ALL grid segments.""" if not grid_data: return [] - grid = grid_data[0] - validation_info = [] - - for row_data in grid.get("rowData", []): - row_validation = [] - for cell_data in row_data.get("values", []): - validation_rule = cell_data.get("dataValidation", {}) - if validation_rule: - row_validation.append(validation_rule) - else: - row_validation.append({}) - validation_info.append(row_validation) + # Process ALL grids, not just the first + validation_info: List[List[dict]] = [] + for grid in grid_data: + for row_data in grid.get("rowData", []) or []: + row_validation: List[dict] = [] + for cell_data in row_data.get("values", []) or []: + validation_rule = cell_data.get("dataValidation", {}) + if validation_rule: + row_validation.append(validation_rule) + else: + row_validation.append({}) + validation_info.append(row_validation) return validation_info diff --git a/libs/community/langchain_google_community/sheets/read_sheet_tools.py b/libs/community/langchain_google_community/sheets/read_sheet_tools.py index 7cceb9b96..42faedbad 100644 --- a/libs/community/langchain_google_community/sheets/read_sheet_tools.py +++ b/libs/community/langchain_google_community/sheets/read_sheet_tools.py @@ -4,16 +4,14 @@ base schemas, base tool class, and specific read implementations. """ -import json -from typing import List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union from langchain_core.callbacks import CallbackManagerForToolRun -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from .base import SheetsBaseTool from .enums import ( DateTimeRenderOption, - FilterConditionType, MajorDimension, ValueRenderOption, ) @@ -148,32 +146,48 @@ def _process_data( return values - def _extract_simple_data(self, grid_data: List[dict]) -> List[List[str]]: - """Extract simple 2D array from complex GridData structure. + def _extract_simple_data_all(self, grid_data: List[dict]) -> List[List[str]]: + """Extract a simple 2D array from ALL GridData segments, preserving order. + + This method processes ALL grid segments returned by the API, not just the + first one. This is critical for preventing data loss when the API returns + multiple segments (e.g., filtered reads, paginated responses). Args: - grid_data: GridData from Google Sheets API + grid_data: List of GridData segments from Google Sheets API Returns: - List[List[str]]: Simple 2D array of values + List[List[str]]: Simple 2D array of values from all segments concatenated """ if not grid_data: return [] - # Get the first GridData (usually contains all data) - grid = grid_data[0] - - # Extract simple data using safe extraction - result = [] - for row_data in grid.get("rowData", []): - row_values = [] - for cell_data in row_data.get("values", []): - value = self._safe_get_cell_value(cell_data) - row_values.append(value) - result.append(row_values) + result: List[List[str]] = [] + for grid in grid_data: + for row_data in grid.get("rowData", []) or []: + row_values: List[str] = [] + for cell_data in row_data.get("values", []) or []: + row_values.append(self._safe_get_cell_value(cell_data)) + # Keep empty rows as [] if the API returns them + result.append(row_values) return result + def _extract_simple_data(self, grid_data: List[dict]) -> List[List[str]]: + """Extract simple 2D array from complex GridData structure. + + Backward-compatible method name. Now processes ALL segments to prevent + data loss. + + Args: + grid_data: GridData from Google Sheets API + + Returns: + List[List[str]]: Simple 2D array of values from all segments + """ + # Delegate to the new multi-segment implementation + return self._extract_simple_data_all(grid_data) + # ============================================================================ # 3. READ SHEET DATA (Schema + Tool) @@ -227,11 +241,14 @@ class SheetsReadDataTool(BaseReadTool): ) Returns: - JSON string containing: - - Raw data: 2D array of values as returned by Google Sheets API - - Records format: List of dictionaries with first row as headers - - Metadata: Information about the data structure and processing - - Error handling: Detailed error messages for troubleshooting + Dictionary containing: + - success (bool): Always True for successful operations + - spreadsheet_id (str): The spreadsheet ID + - range (str): The actual range that was read (A1 notation) + - values (List or List[Dict]): Processed data (2D array or records) + - major_dimension (str): The major dimension ("ROWS" or "COLUMNS") + - render_options (Dict): Applied rendering options + - processing_options (Dict): Applied processing options Data Processing Options: - value_render_option: Control how cell values are rendered @@ -244,16 +261,12 @@ class SheetsReadDataTool(BaseReadTool): - convert_to_records: Transform 2D array to list of dictionaries - numericise_values: Automatically convert numeric strings to numbers - Example Response: + Example Response (with convert_to_records=True): { + "success": True, "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "range_name": "A1:E3", + "range": "Class Data!A1:E3", "values": [ - ["Name", "Age", "Grade", "Subject", "Score"], - ["Alice", "16", "10th", "Math", "95"], - ["Bob", "17", "11th", "Science", "87"] - ], - "records": [ { "Name": "Alice", "Age": 16, "Grade": "10th", "Subject": "Math", "Score": 95 @@ -263,11 +276,14 @@ class SheetsReadDataTool(BaseReadTool): "Subject": "Science", "Score": 87 } ], - "metadata": { - "total_rows": 3, - "total_columns": 5, - "has_headers": true, - "data_types": ["string", "number", "string", "string", "number"] + "major_dimension": "ROWS", + "render_options": { + "value_render_option": "FORMATTED_VALUE", + "date_time_render_option": "SERIAL_NUMBER" + }, + "processing_options": { + "convert_to_records": True, + "numericise_values": True } } @@ -296,7 +312,7 @@ def _run( convert_to_records: bool = False, numericise_values: bool = True, run_manager: Optional[CallbackManagerForToolRun] = None, - ) -> str: + ) -> Dict[str, Any]: """Read data from a Google Spreadsheet.""" try: # Validate inputs @@ -327,6 +343,8 @@ def _run( ) result = { + "success": True, + "spreadsheet_id": spreadsheet_id, "range": response.get("range", range_name), "values": processed_data, "major_dimension": response.get("majorDimension", "ROWS"), @@ -340,7 +358,7 @@ def _run( }, } - return json.dumps(result, indent=2, default=str) + return result except Exception as error: raise Exception(f"Error reading sheet data: {error}") from error @@ -401,11 +419,19 @@ class SheetsBatchReadDataTool(BaseReadTool): agent.invoke({"input": "Read data from multiple ranges in the spreadsheet"}) Returns: - JSON string containing: - - Batch results: Dictionary with range names as keys - - Individual range data: Each range processed according to options - - Metadata: Information about each range and processing results - - Error handling: Detailed error messages for failed ranges + Dictionary containing: + - success (bool): Always True for successful operations + - spreadsheet_id (str): The spreadsheet ID + - requested_ranges (List[str]): The ranges that were requested + - total_ranges (int): Total number of ranges processed + - successful_ranges (int): Number of successfully processed ranges + - failed_ranges (int): Number of failed ranges + - results (List[Dict]): List of results for each range + - value_render_option (str): Applied value rendering option + - date_time_render_option (str): Applied date/time rendering option + - major_dimension (str): Applied major dimension + - convert_to_records (bool): Whether data was converted to records + - numericise_values (bool): Whether values were numericised Performance Benefits: - Single API call: Reduces network overhead and rate limiting @@ -413,40 +439,40 @@ class SheetsBatchReadDataTool(BaseReadTool): - Efficient batching: Optimized for multiple data extraction scenarios - Consistent formatting: All ranges processed with same options - Example Response: + Example Response (with convert_to_records=True): { + "success": True, "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "ranges": ["A1:C3", "F1:H3"], - "results": { - "A1:C3": { - "values": [ - ["Name", "Age", "Grade"], - ["Alice", "16", "10th"], - ["Bob", "17", "11th"] - ], - "records": [ + "requested_ranges": ["Class Data!A1:C3", "Class Data!F1:H3"], + "total_ranges": 2, + "successful_ranges": 2, + "failed_ranges": 0, + "results": [ + { + "range": "Class Data!A1:C3", + "data": [ {"Name": "Alice", "Age": 16, "Grade": "10th"}, {"Name": "Bob", "Age": 17, "Grade": "11th"} - ] + ], + "error": None }, - "F1:H3": { - "values": [ - ["Subject", "Score", "Date"], - ["Math", "95", "2024-01-15"], - ["Science", "87", "2024-01-16"] + { + "range": "Class Data!F1:H3", + "data": [ + {"Subject": "Math", "Score": 95, "Teacher": "Mr. Smith"}, + { + "Subject": "Science", "Score": 87, + "Teacher": "Ms. Johnson" + } ], - "records": [ - {"Subject": "Math", "Score": 95, "Date": "2024-01-15"}, - {"Subject": "Science", "Score": 87, "Date": "2024-01-16"} - ] + "error": None } - }, - "metadata": { - "total_ranges": 2, - "successful_ranges": 2, - "failed_ranges": 0, - "processing_time": "0.5s" - } + ], + "value_render_option": "FORMATTED_VALUE", + "date_time_render_option": "SERIAL_NUMBER", + "major_dimension": "ROWS", + "convert_to_records": True, + "numericise_values": True } Raises: @@ -475,7 +501,7 @@ def _run( convert_to_records: bool = False, numericise_values: bool = True, run_manager: Optional[CallbackManagerForToolRun] = None, - ) -> str: + ) -> Dict[str, Any]: """Read data from multiple ranges in a Google Spreadsheet.""" try: # Validate inputs @@ -534,6 +560,7 @@ def _run( ) batch_metadata = { + "success": True, "spreadsheet_id": spreadsheet_id, "requested_ranges": ranges, "value_render_option": value_render_option.value, @@ -547,7 +574,7 @@ def _run( "results": results, } - return json.dumps(batch_metadata, indent=2, default=str) + return batch_metadata except Exception as error: raise Exception(f"Error batch reading sheet data: {error}") from error @@ -559,28 +586,67 @@ def _run( class DataFilterSchema(BaseModel): - """Schema for data filter criteria.""" + """Schema for DataFilter used with getByDataFilter API. + + DataFilters specify which ranges or metadata to read from a spreadsheet. + Must specify exactly ONE of: a1Range, gridRange, or developerMetadataLookup. + + Note: This is for range selection, NOT conditional filtering (like "score > 50"). + For conditional filtering, use Filter Views via the batchUpdate API. + + See: https://developers.google.com/sheets/api/reference/rest/v4/DataFilter + """ - column_index: int = Field( - ..., - description="The column index (0-based) to filter on.", + a1Range: Optional[str] = Field( + None, + description=( + "A1 notation range to read (e.g., 'Sheet1!A1:D5', 'NamedRange'). " + "This is the most common way to specify a range." + ), ) - condition: FilterConditionType = Field( - ..., - description="The condition type to apply for filtering.", + gridRange: Optional[Dict[str, Any]] = Field( + None, + description=( + "Grid coordinates to read. Example: " + "{'sheetId': 0, 'startRowIndex': 0, 'endRowIndex': 10, " + "'startColumnIndex': 0, 'endColumnIndex': 5}" + ), ) - value: str = Field( - ..., - description="The value to compare against.", + developerMetadataLookup: Optional[Dict[str, Any]] = Field( + None, + description=( + "Developer metadata lookup to select ranges. " + "Advanced feature for ranges tagged with metadata." + ), ) + @model_validator(mode="after") + def check_one_filter_type(self) -> "DataFilterSchema": + """Validate that exactly one filter type is specified.""" + count = sum( + [ + bool(self.a1Range), + bool(self.gridRange), + bool(self.developerMetadataLookup), + ] + ) + if count != 1: + raise ValueError( + "Must specify exactly one of: a1Range, gridRange, or " + "developerMetadataLookup" + ) + return self + class FilteredReadSheetDataSchema(ReadBaseSchema): - """Input schema for FilteredReadSheetData.""" + """Input schema for reading data using DataFilters (getByDataFilter API).""" data_filters: List[DataFilterSchema] = Field( ..., - description="List of data filters to apply to the spreadsheet.", + description=( + "List of DataFilters specifying which ranges to read. " + "Each filter selects a range using a1Range, gridRange, or metadata lookup." + ), ) include_grid_data: bool = Field( default=False, @@ -592,12 +658,24 @@ class FilteredReadSheetDataSchema(ReadBaseSchema): class SheetsFilteredReadDataTool(BaseReadTool): - """Tool that reads data from Google Sheets with advanced filtering capabilities. - - This tool provides advanced data filtering capabilities using Google Sheets' - getByDataFilter API method. It allows you to apply complex filtering conditions - to extract specific data based on criteria, supporting various data types and - comparison operators. Requires OAuth2 authentication for full functionality. + """Tool that reads data from Google Sheets using DataFilters (getByDataFilter API). + + This tool reads data from spreadsheets using the getByDataFilter API, which + allows you to specify ranges using A1 notation, grid coordinates, or developer + metadata. It also provides detailed cell formatting and properties when + include_grid_data=True. + + Note: This tool is for RANGE SELECTION, not conditional filtering like + "score > 50". For conditional filtering, create a Filter View using the + Sheets UI or batchUpdate API. + + Use cases: + - Read multiple ranges in a single API call + - Get detailed cell formatting and properties (gridData) + - Select ranges by developer metadata tags + - Use grid coordinates instead of A1 notation + + Requires OAuth2 authentication for full functionality. Instantiate: .. code-block:: python @@ -614,14 +692,30 @@ class SheetsFilteredReadDataTool(BaseReadTool): Invoke directly: .. code-block:: python + # Example 1: Read using A1 notation + result = tool.run( + { + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "data_filters": [ + {"a1Range": "Class Data!A1:E10"} + ], + "include_grid_data": True, + } + ) + + # Example 2: Read using grid coordinates result = tool.run( { "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", "data_filters": [ { - "column_index": 1, - "condition": "NUMBER_GREATER", - "value": "50", + "gridRange": { + "sheetId": 0, + "startRowIndex": 0, + "endRowIndex": 10, + "startColumnIndex": 0, + "endColumnIndex": 5 + } } ], "include_grid_data": True, @@ -631,50 +725,63 @@ class SheetsFilteredReadDataTool(BaseReadTool): Invoke with agent: .. code-block:: python - agent.invoke({"input": "Find all students with scores above 80"}) + agent.invoke({ + "input": "Read the range Class Data!A1:E10 with formatting details" + }) Returns: - JSON string containing: - - Filtered data: Only rows matching the filter criteria - - Grid data: Detailed cell information with formatting (optional) - - Metadata: Information about filtering results and data structure - - Error handling: Detailed error messages for troubleshooting - - Filter Conditions Available: - - Number conditions: GREATER, LESS, EQUAL, BETWEEN, etc. - - Text conditions: CONTAINS, STARTS_WITH, ENDS_WITH, EQUAL, etc. - - Date conditions: IS_AFTER, IS_BEFORE, IS_ON_OR_AFTER, etc. - - Boolean conditions: IS_TRUE, IS_FALSE + Dictionary containing: + - success (bool): Always True for successful operations + - spreadsheet_id (str): The spreadsheet ID + - properties (Dict): Spreadsheet-level properties + - sheets (List[Dict]): List of sheets with filtered data + Each sheet contains: + - properties (Dict): Sheet properties (title, sheetId, etc.) + - data (List): List of data segments + Each segment is either List[List] or List[Dict] + depending on convert_to_records setting + + DataFilter Types: + - a1Range: Most common, uses A1 notation (e.g., "Sheet1!A1:D5") + - gridRange: Uses grid coordinates (sheetId, row/column indices) + - developerMetadataLookup: Selects ranges tagged with metadata Authentication Requirements: - Requires OAuth2 credentials (not API key) - Full access to spreadsheet data - Supports private and shared spreadsheets - Example Response: + Example Response (with convert_to_records=True): { + "success": True, "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "data_filters": [ + "properties": { + "title": "Student Data", + "locale": "en_US", + "timeZone": "America/New_York" + }, + "sheets": [ { - "column_index": 1, - "condition": "NUMBER_GREATER", - "value": "50" + "properties": { + "sheetId": 0, + "title": "Students", + "index": 0, + "sheetType": "GRID" + }, + "data": [ + [ + { + "Name": "Alice", "Score": 95, + "Subject": "Math", "Grade": "A" + }, + { + "Name": "Charlie", "Score": 87, + "Subject": "Science", "Grade": "B" + } + ] + ] } - ], - "filtered_data": [ - ["Alice", "95", "Math", "A"], - ["Charlie", "87", "Science", "B"] - ], - "records": [ - {"Name": "Alice", "Score": 95, "Subject": "Math", "Grade": "A"}, - {"Name": "Charlie", "Score": 87, "Subject": "Science", "Grade": "B"} - ], - "metadata": { - "total_matching_rows": 2, - "total_rows_scanned": 10, - "filter_applied": "Score > 50", - "processing_time": "0.3s" - } + ] } Raises: @@ -682,36 +789,16 @@ class SheetsFilteredReadDataTool(BaseReadTool): Exception: For API errors, authentication issues, or connection problems """ - name: str = "sheets_filtered_read_data" + name: str = "sheets_get_by_data_filter" description: str = ( - "Read data from Google Sheets with advanced filtering capabilities using " - "getByDataFilter API. Supports complex filtering conditions, grid data " - "extraction, and various data types. Requires OAuth2 authentication for " - "full functionality. Perfect for extracting specific data based on criteria." + "Read data from Google Sheets using DataFilters (getByDataFilter API). " + "Select ranges using A1 notation, grid coordinates, or developer metadata. " + "Optionally include detailed cell formatting and properties (gridData). " + "Useful for reading multiple ranges or ranges with specific metadata tags. " + "Requires OAuth2 authentication." ) args_schema: Type[BaseModel] = FilteredReadSheetDataSchema - def _convert_to_dict_list(self, items: list) -> list: - """Convert a list of items to dictionaries. - - Handles both Pydantic models and dicts. - - Args: - items: List of items that may be Pydantic models or dictionaries - - Returns: - list: List of dictionaries - """ - result = [] - for item in items: - if hasattr(item, "model_dump"): - # It's a Pydantic model, convert to dict - result.append(item.model_dump()) - else: - # It's already a dict - result.append(item) - return result - def _run( self, spreadsheet_id: str, @@ -724,7 +811,7 @@ def _run( convert_to_records: bool = False, numericise_values: bool = True, run_manager: Optional[CallbackManagerForToolRun] = None, - ) -> str: + ) -> Dict[str, Any]: """Read data from Google Sheets with filtering.""" try: # Validate inputs @@ -756,7 +843,10 @@ def _run( response, convert_to_records, numericise_values ) - return json.dumps(result, indent=2, default=str) + # Add success field + result["success"] = True + + return result except Exception as error: raise Exception(f"Error filtered reading sheet data: {error}") from error diff --git a/libs/community/langchain_google_community/sheets/toolkit.py b/libs/community/langchain_google_community/sheets/toolkit.py index 22650c9fd..da812a85f 100644 --- a/libs/community/langchain_google_community/sheets/toolkit.py +++ b/libs/community/langchain_google_community/sheets/toolkit.py @@ -4,6 +4,9 @@ from langchain_core.tools.base import BaseToolkit from pydantic import ConfigDict, Field +from langchain_google_community.sheets.create_spreadsheet_tool import ( + SheetsCreateSpreadsheetTool, +) from langchain_google_community.sheets.get_spreadsheet_info import ( SheetsGetSpreadsheetInfoTool, ) @@ -12,6 +15,12 @@ SheetsFilteredReadDataTool, SheetsReadDataTool, ) +from langchain_google_community.sheets.write_sheet_tools import ( + SheetsAppendValuesTool, + SheetsBatchUpdateValuesTool, + SheetsClearValuesTool, + SheetsUpdateValuesTool, +) if TYPE_CHECKING: # This is for linting and IDE typehints @@ -27,15 +36,28 @@ class SheetsToolkit(BaseToolkit): """Toolkit for interacting with Google Sheets. - *Security Note*: This toolkit contains tools that can read data from - Google Sheets. Currently, only read operations are supported. + This toolkit provides comprehensive Google Sheets integration with both + read and write capabilities. + + *Security Note*: This toolkit contains tools that can read and write data + to Google Sheets. - For example, this toolkit can be used to read spreadsheet data, - get spreadsheet metadata, and perform filtered data queries. + Read operations: Can use either API key (public sheets) or OAuth2 + Write operations: Require OAuth2 credentials (api_resource) + + For example, this toolkit can be used to: + - Read spreadsheet data and metadata + - Create new spreadsheets + - Update, append, and clear cell values + - Perform batch operations for efficiency + + Authentication: + - api_resource: OAuth2 credentials for full read/write access + - api_key: API key for read-only access to public spreadsheets """ - api_resource: Resource = Field(default=None) + api_resource: Resource = Field(default=None) # type: ignore[assignment] api_key: Optional[str] = Field(default=None) model_config = ConfigDict( @@ -43,20 +65,33 @@ class SheetsToolkit(BaseToolkit): ) def get_tools(self) -> List[BaseTool]: - """Get the tools in the toolkit.""" - # If api_key is provided, use it for all tools + """Get the tools in the toolkit. + + Returns: + List[BaseTool]: List of tools available based on authentication method. + - API key: Read-only tools (public spreadsheets) + - OAuth2: Full read/write tools (private spreadsheets) + """ + # If api_key is provided, return read-only tools if self.api_key: return [ SheetsReadDataTool(api_key=self.api_key), SheetsBatchReadDataTool(api_key=self.api_key), SheetsGetSpreadsheetInfoTool(api_key=self.api_key), - # Note: FilteredReadDataTool requires OAuth2, not API key + # Note: Write operations and FilteredReadDataTool require OAuth2 ] - # Otherwise, use the api_resource (OAuth2) + # Otherwise, use the api_resource (OAuth2) for full read/write access return [ + # Read operations SheetsReadDataTool(api_resource=self.api_resource), SheetsBatchReadDataTool(api_resource=self.api_resource), SheetsFilteredReadDataTool(api_resource=self.api_resource), SheetsGetSpreadsheetInfoTool(api_resource=self.api_resource), + # Write operations (OAuth2 only) + SheetsCreateSpreadsheetTool(api_resource=self.api_resource), + SheetsUpdateValuesTool(api_resource=self.api_resource), + SheetsAppendValuesTool(api_resource=self.api_resource), + SheetsClearValuesTool(api_resource=self.api_resource), + SheetsBatchUpdateValuesTool(api_resource=self.api_resource), ] diff --git a/libs/community/langchain_google_community/sheets/urls.py b/libs/community/langchain_google_community/sheets/urls.py deleted file mode 100644 index e76c845c4..000000000 --- a/libs/community/langchain_google_community/sheets/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Google Sheets API URLs and endpoints for read-only operations. - -This module contains URL patterns and endpoints for Google Sheets API v4 -read-only operations. These URLs are used for reading spreadsheet data -without modification capabilities. -""" - -# Base URLs -SPREADSHEETS_API_V4_BASE_URL: str = "https://sheets.googleapis.com/v4/spreadsheets" - -# Read-only Spreadsheet URLs -SPREADSHEET_URL: str = SPREADSHEETS_API_V4_BASE_URL + "/%s" -SPREADSHEET_GET_BY_DATA_FILTER_URL: str = ( - SPREADSHEETS_API_V4_BASE_URL + "/%s:getByDataFilter" -) - -# Read-only Values URLs -SPREADSHEET_VALUES_URL: str = SPREADSHEETS_API_V4_BASE_URL + "/%s/values/%s" -SPREADSHEET_VALUES_BATCH_URL: str = SPREADSHEETS_API_V4_BASE_URL + "/%s/values:batchGet" diff --git a/libs/community/langchain_google_community/sheets/utils.py b/libs/community/langchain_google_community/sheets/utils.py index e237ea457..f00cb9446 100644 --- a/libs/community/langchain_google_community/sheets/utils.py +++ b/libs/community/langchain_google_community/sheets/utils.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +import re +from typing import TYPE_CHECKING, List, Optional, Tuple from langchain_google_community._utils import ( get_google_credentials, @@ -39,19 +40,13 @@ def build_sheets_service( Returns: Resource: Google Sheets API service with full access capabilities. """ - # Default scopes for full access + # Default scopes for full access (read/write) + # Note: Use scopes parameter to override if read-only access is desired default_scopes = [ "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive.readonly", ] - if use_domain_wide: - # Scopes for domain-wide delegation (can be read-only or read-write) - default_scopes = [ - "https://www.googleapis.com/auth/spreadsheets.readonly", - "https://www.googleapis.com/auth/drive.readonly", - ] - scopes = scopes or default_scopes credentials = credentials or get_google_credentials( @@ -113,9 +108,106 @@ def validate_spreadsheet_id(spreadsheet_id: str) -> str: return spreadsheet_id +# These patterns and validators are actively used by all read/write tools +# to ensure proper A1 notation format before making API calls. + +# A1 notation regex patterns +# Single cell: A1, Z99 (column letters + row starting with 1-9) +_CELL = re.compile(r"^[A-Za-z]+[1-9]\d*$") +# Area: A1:B2 (two cells separated by colon) +_AREA = re.compile(r"^[A-Za-z]+[1-9]\d*:[A-Za-z]+[1-9]\d*$") +# Whole columns: A:A, B:D (column letters on both sides) +_COLS = re.compile(r"^[A-Za-z]+:[A-Za-z]+$") +# Whole rows: 1:1, 5:10 (row numbers starting with 1-9 on both sides) +_ROWS = re.compile(r"^[1-9]\d*:[1-9]\d*$") +# Named range: MyData, Sales_2024 (alphanumeric + underscore, letter/underscore start) +_NAMED = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _validate_target(target: str) -> bool: + """Validate a range target (after sheet qualifier removed). + + Args: + target: Range string without sheet qualifier. + + Returns: + True if valid, False otherwise. + """ + # Check specific patterns first (more restrictive) + if _AREA.fullmatch(target): + return True + if _CELL.fullmatch(target): + return True + if _COLS.fullmatch(target): + return True + if _ROWS.fullmatch(target): + return True + # Check named range last (least restrictive) + # Only allow if it looks like a name, not like a malformed cell reference + if _NAMED.fullmatch(target): + # Reject simple cell-ish names that end with 0 (e.g., "A0") + if target[-1] == "0" and any(c.isalpha() for c in target): + return False + return True + return False + + +def validate_a1_range(range_name: str) -> bool: + """Validate A1 notation range format. + + Supports: + - Single cells: "A1", "Z99" + - Areas: "A1:B2" + - Whole columns: "A:A", "B:D" + - Whole rows: "1:1", "5:10" + - Sheet-qualified: "Sheet1!A1", "'My Sheet'!A1:B2" + - Named ranges: "MyData", "Sales_2024" + + Args: + range_name: Range string to validate. + + Returns: + True if valid format, False otherwise. + + Examples: + >>> validate_a1_range("A1") + True + >>> validate_a1_range("A1:B2") + True + >>> validate_a1_range("Sheet1!A1") + True + >>> validate_a1_range("A:A") + True + >>> validate_a1_range("") + False + """ + if not range_name or not range_name.strip(): + return False + if range_name.startswith("!"): + return False + + sheet, had_bang, after = range_name.partition("!") + if had_bang: + if not after: # "Sheet1!" -> invalid + return False + target = after + else: + target = range_name + + return _validate_target(target) + + def validate_range_name(range_name: str) -> str: """Validate and normalize a range name. + Permissive A1 validator. Supports: + - Single cells: "A1", "Z99" + - Areas: "A1:B2", "AA1:BB10" + - Whole columns: "A:A", "B:D" + - Whole rows: "1:1", "5:10" + - Sheet-qualified: "Sheet1!A1", "'My Sheet'!A1:B2" + - Named ranges: "MyData", "Sales_2024" (let API validate) + Args: range_name: The range name to validate (e.g., "A1:Z100", "Sheet1!A1:B2"). @@ -124,12 +216,24 @@ def validate_range_name(range_name: str) -> str: Raises: ValueError: If the range name is invalid. - """ - if not range_name: - raise ValueError("Range name cannot be empty") - # Basic validation - should contain at least one colon for range - if ":" not in range_name and not range_name.isalpha(): - raise ValueError(f"Invalid range format: {range_name}") - - return range_name + Examples: + >>> validate_range_name("A1") + 'A1' + >>> validate_range_name("Sheet1!A1:B2") + 'Sheet1!A1:B2' + >>> validate_range_name("A:A") + 'A:A' + >>> validate_range_name("") + Traceback (most recent call last): + ... + ValueError: Invalid range format: ... + """ + if not validate_a1_range(range_name): + raise ValueError( + f"Invalid range format: {range_name}. " + "Expected A1 notation like 'A1', 'A1:B2', 'Sheet1!A1', 'A:A', or '1:1'. " + "Named ranges are allowed." + ) + + return range_name \ No newline at end of file From 2b397c0016351f514eff86d70516367baa3ea056 Mon Sep 17 00:00:00 2001 From: von-development Date: Sat, 4 Oct 2025 10:10:17 +0200 Subject: [PATCH 3/4] test(community): add unit tests for write tools and update integration tests - Add unit tests for all 5 new write tools - Add unit test for SheetsFilteredReadDataTool - Update integration tests to handle dict returns instead of JSON strings - Update toolkit tests to verify write tools require OAuth2 --- .../test_sheets_integration.py | 57 +++++---- .../community/tests/unit_tests/test_sheets.py | 114 +++++++++++++++++- 2 files changed, 142 insertions(+), 29 deletions(-) diff --git a/libs/community/tests/integration_tests/test_sheets_integration.py b/libs/community/tests/integration_tests/test_sheets_integration.py index 9800025cb..19a6d4434 100644 --- a/libs/community/tests/integration_tests/test_sheets_integration.py +++ b/libs/community/tests/integration_tests/test_sheets_integration.py @@ -1,6 +1,5 @@ """Integration tests for Google Sheets tools.""" -import json import os import pytest @@ -42,12 +41,14 @@ def test_read_sheet_data(sheets_api_key: str, test_spreadsheet_id: str) -> None: } ) - # Parse and verify JSON response - data = json.loads(result) - assert "range" in data - assert "values" in data - assert len(data["values"]) > 0 - assert "A1:C3" in data["range"] + # Verify dict response (tools now return dicts, not JSON strings) + assert isinstance(result, dict) + assert "success" in result + assert result["success"] is True + assert "range" in result + assert "values" in result + assert len(result["values"]) > 0 + assert "A1:C3" in result["range"] except Exception as e: if "SERVICE_DISABLED" in str(e) or "API has not been used" in str(e): pytest.skip("Google Sheets API not enabled in CI environment") @@ -68,11 +69,13 @@ def test_batch_read_sheet_data(sheets_api_key: str, test_spreadsheet_id: str) -> } ) - # Parse and verify JSON response - data = json.loads(result) - assert "spreadsheet_id" in data - assert "results" in data - assert len(data["results"]) == 2 + # Verify dict response (tools now return dicts, not JSON strings) + assert isinstance(result, dict) + assert "success" in result + assert result["success"] is True + assert "spreadsheet_id" in result + assert "results" in result + assert len(result["results"]) == 2 except Exception as e: if "SERVICE_DISABLED" in str(e) or "API has not been used" in str(e): pytest.skip("Google Sheets API not enabled in CI environment") @@ -92,12 +95,14 @@ def test_get_spreadsheet_info(sheets_api_key: str, test_spreadsheet_id: str) -> } ) - # Parse and verify JSON response - data = json.loads(result) - assert "spreadsheet_id" in data - assert "title" in data - assert "sheets" in data - assert len(data["sheets"]) > 0 + # Verify dict response (tools now return dicts, not JSON strings) + assert isinstance(result, dict) + assert "success" in result + assert result["success"] is True + assert "spreadsheet_id" in result + assert "title" in result + assert "sheets" in result + assert len(result["sheets"]) > 0 except Exception as e: if "SERVICE_DISABLED" in str(e) or "API has not been used" in str(e): pytest.skip("Google Sheets API not enabled in CI environment") @@ -106,11 +111,11 @@ def test_get_spreadsheet_info(sheets_api_key: str, test_spreadsheet_id: str) -> @pytest.mark.extended def test_toolkit_functionality(sheets_api_key: str, test_spreadsheet_id: str) -> None: - """Test SheetsToolkit functionality.""" + """Test SheetsToolkit functionality with API key (read-only).""" toolkit = SheetsToolkit(api_key=sheets_api_key) tools = toolkit.get_tools() - # Should have 3 tools (excludes filtered read which requires OAuth2) + # Should have 3 read-only tools (no write tools with API key) assert len(tools) == 3 try: @@ -123,11 +128,13 @@ def test_toolkit_functionality(sheets_api_key: str, test_spreadsheet_id: str) -> } ) - # Verify the result - data = json.loads(result) - assert "range" in data - assert "values" in data - assert "A1:B2" in data["range"] + # Verify dict response (tools now return dicts, not JSON strings) + assert isinstance(result, dict) + assert "success" in result + assert result["success"] is True + assert "range" in result + assert "values" in result + assert "A1:B2" in result["range"] except Exception as e: if "SERVICE_DISABLED" in str(e) or "API has not been used" in str(e): pytest.skip("Google Sheets API not enabled in CI environment") diff --git a/libs/community/tests/unit_tests/test_sheets.py b/libs/community/tests/unit_tests/test_sheets.py index 9fbe5495d..5266533b9 100644 --- a/libs/community/tests/unit_tests/test_sheets.py +++ b/libs/community/tests/unit_tests/test_sheets.py @@ -3,10 +3,16 @@ from unittest.mock import MagicMock from langchain_google_community.sheets import ( + SheetsAppendValuesTool, SheetsBatchReadDataTool, + SheetsBatchUpdateValuesTool, + SheetsClearValuesTool, + SheetsCreateSpreadsheetTool, + SheetsFilteredReadDataTool, SheetsGetSpreadsheetInfoTool, SheetsReadDataTool, SheetsToolkit, + SheetsUpdateValuesTool, ) @@ -54,22 +60,122 @@ def test_sheets_get_spreadsheet_info_tool() -> None: assert "spreadsheet_id" in schema_fields +def test_sheets_filtered_read_data_tool() -> None: + """Test SheetsFilteredReadDataTool basic functionality.""" + mock_api_resource = MagicMock() + tool = SheetsFilteredReadDataTool.model_construct(api_resource=mock_api_resource) + + # Test that the tool has the correct schema + assert tool.args_schema is not None + assert tool.name == "sheets_get_by_data_filter" + + # Test that required fields are present + schema_fields = tool.args_schema.model_fields + assert "spreadsheet_id" in schema_fields + assert "data_filters" in schema_fields + + +def test_sheets_create_spreadsheet_tool() -> None: + """Test SheetsCreateSpreadsheetTool basic functionality.""" + mock_api_resource = MagicMock() + tool = SheetsCreateSpreadsheetTool.model_construct(api_resource=mock_api_resource) + + # Test that the tool has the correct schema + assert tool.args_schema is not None + assert tool.name == "sheets_create_spreadsheet" + + # Test that required fields are present + schema_fields = tool.args_schema.model_fields + assert "title" in schema_fields + + +def test_sheets_update_values_tool() -> None: + """Test SheetsUpdateValuesTool basic functionality.""" + mock_api_resource = MagicMock() + tool = SheetsUpdateValuesTool.model_construct(api_resource=mock_api_resource) + + # Test that the tool has the correct schema + assert tool.args_schema is not None + assert tool.name == "sheets_update_values" + + # Test that required fields are present + schema_fields = tool.args_schema.model_fields + assert "spreadsheet_id" in schema_fields + assert "range" in schema_fields + assert "values" in schema_fields + + +def test_sheets_append_values_tool() -> None: + """Test SheetsAppendValuesTool basic functionality.""" + mock_api_resource = MagicMock() + tool = SheetsAppendValuesTool.model_construct(api_resource=mock_api_resource) + + # Test that the tool has the correct schema + assert tool.args_schema is not None + assert tool.name == "sheets_append_values" + + # Test that required fields are present + schema_fields = tool.args_schema.model_fields + assert "spreadsheet_id" in schema_fields + assert "range" in schema_fields + assert "values" in schema_fields + + +def test_sheets_clear_values_tool() -> None: + """Test SheetsClearValuesTool basic functionality.""" + mock_api_resource = MagicMock() + tool = SheetsClearValuesTool.model_construct(api_resource=mock_api_resource) + + # Test that the tool has the correct schema + assert tool.args_schema is not None + assert tool.name == "sheets_clear_values" + + # Test that required fields are present + schema_fields = tool.args_schema.model_fields + assert "spreadsheet_id" in schema_fields + assert "range" in schema_fields + + +def test_sheets_batch_update_values_tool() -> None: + """Test SheetsBatchUpdateValuesTool basic functionality.""" + mock_api_resource = MagicMock() + tool = SheetsBatchUpdateValuesTool.model_construct(api_resource=mock_api_resource) + + # Test that the tool has the correct schema + assert tool.args_schema is not None + assert tool.name == "sheets_batch_update_values" + + # Test that required fields are present + schema_fields = tool.args_schema.model_fields + assert "spreadsheet_id" in schema_fields + assert "data" in schema_fields + + def test_sheets_toolkit_with_api_key() -> None: - """Test SheetsToolkit with API key authentication.""" + """Test SheetsToolkit with API key (read-only).""" toolkit = SheetsToolkit(api_key="test_api_key") tools = toolkit.get_tools() - # Should return 3 tools (excludes filtered read which requires OAuth2) + # Should return 3 read-only tools (no write tools with API key) assert len(tools) == 3 tool_names = [tool.name for tool in tools] + + # Read-only tools should be present assert "sheets_read_data" in tool_names assert "sheets_batch_read_data" in tool_names assert "sheets_get_spreadsheet_info" in tool_names - assert "sheets_filtered_read_data" not in tool_names + + # OAuth2-only tools should NOT be present + assert "sheets_get_by_data_filter" not in tool_names + assert "sheets_create_spreadsheet" not in tool_names + assert "sheets_update_values" not in tool_names + assert "sheets_append_values" not in tool_names + assert "sheets_clear_values" not in tool_names + assert "sheets_batch_update_values" not in tool_names def test_sheets_toolkit_with_oauth2() -> None: - """Test SheetsToolkit with OAuth2 authentication.""" + """Test SheetsToolkit with OAuth2 (full access).""" # Test that toolkit can be instantiated with OAuth2 toolkit = SheetsToolkit() From 6296567c1d3bcce930b5d1811bc2e4a41eac2e00 Mon Sep 17 00:00:00 2001 From: von-development Date: Sat, 4 Oct 2025 10:22:47 +0200 Subject: [PATCH 4/4] style: apply ruff formatting to sheets module - Add missing newlines at end of files - Fix blank line spacing in docstrings --- .../langchain_google_community/sheets/base.py | 2 +- .../sheets/read_sheet_tools.py | 26 +++++++++---------- .../sheets/utils.py | 4 +-- .../sheets/write_sheet_tools.py | 2 +- .../community/tests/unit_tests/test_sheets.py | 4 +-- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/libs/community/langchain_google_community/sheets/base.py b/libs/community/langchain_google_community/sheets/base.py index 09306b6d4..ee99a8558 100644 --- a/libs/community/langchain_google_community/sheets/base.py +++ b/libs/community/langchain_google_community/sheets/base.py @@ -150,4 +150,4 @@ def _convert_to_dict_list(self, items: list) -> list: else: # It's already a dict result.append(item) - return result \ No newline at end of file + return result diff --git a/libs/community/langchain_google_community/sheets/read_sheet_tools.py b/libs/community/langchain_google_community/sheets/read_sheet_tools.py index 42faedbad..1660413e2 100644 --- a/libs/community/langchain_google_community/sheets/read_sheet_tools.py +++ b/libs/community/langchain_google_community/sheets/read_sheet_tools.py @@ -587,13 +587,13 @@ def _run( class DataFilterSchema(BaseModel): """Schema for DataFilter used with getByDataFilter API. - + DataFilters specify which ranges or metadata to read from a spreadsheet. Must specify exactly ONE of: a1Range, gridRange, or developerMetadataLookup. - + Note: This is for range selection, NOT conditional filtering (like "score > 50"). For conditional filtering, use Filter Views via the batchUpdate API. - + See: https://developers.google.com/sheets/api/reference/rest/v4/DataFilter """ @@ -664,17 +664,17 @@ class SheetsFilteredReadDataTool(BaseReadTool): allows you to specify ranges using A1 notation, grid coordinates, or developer metadata. It also provides detailed cell formatting and properties when include_grid_data=True. - + Note: This tool is for RANGE SELECTION, not conditional filtering like "score > 50". For conditional filtering, create a Filter View using the Sheets UI or batchUpdate API. - + Use cases: - Read multiple ranges in a single API call - Get detailed cell formatting and properties (gridData) - Select ranges by developer metadata tags - Use grid coordinates instead of A1 notation - + Requires OAuth2 authentication for full functionality. Instantiate: @@ -696,13 +696,11 @@ class SheetsFilteredReadDataTool(BaseReadTool): result = tool.run( { "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", - "data_filters": [ - {"a1Range": "Class Data!A1:E10"} - ], + "data_filters": [{"a1Range": "Class Data!A1:E10"}], "include_grid_data": True, } ) - + # Example 2: Read using grid coordinates result = tool.run( { @@ -714,7 +712,7 @@ class SheetsFilteredReadDataTool(BaseReadTool): "startRowIndex": 0, "endRowIndex": 10, "startColumnIndex": 0, - "endColumnIndex": 5 + "endColumnIndex": 5, } } ], @@ -725,9 +723,9 @@ class SheetsFilteredReadDataTool(BaseReadTool): Invoke with agent: .. code-block:: python - agent.invoke({ - "input": "Read the range Class Data!A1:E10 with formatting details" - }) + agent.invoke( + {"input": "Read the range Class Data!A1:E10 with formatting details"} + ) Returns: Dictionary containing: diff --git a/libs/community/langchain_google_community/sheets/utils.py b/libs/community/langchain_google_community/sheets/utils.py index f00cb9446..ecb255ca1 100644 --- a/libs/community/langchain_google_community/sheets/utils.py +++ b/libs/community/langchain_google_community/sheets/utils.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, List, Optional from langchain_google_community._utils import ( get_google_credentials, @@ -236,4 +236,4 @@ def validate_range_name(range_name: str) -> str: "Named ranges are allowed." ) - return range_name \ No newline at end of file + return range_name diff --git a/libs/community/langchain_google_community/sheets/write_sheet_tools.py b/libs/community/langchain_google_community/sheets/write_sheet_tools.py index 6ee1f54fc..8d5ac713a 100644 --- a/libs/community/langchain_google_community/sheets/write_sheet_tools.py +++ b/libs/community/langchain_google_community/sheets/write_sheet_tools.py @@ -541,7 +541,7 @@ class SheetsBatchUpdateValuesTool(SheetsBaseTool): from langchain_google_community.sheets import SheetsBatchUpdateValuesTool tool = SheetsBatchUpdateValuesTool( - api_resource=service, + api_resource=service, value_input_option="USER_ENTERED", ) diff --git a/libs/community/tests/unit_tests/test_sheets.py b/libs/community/tests/unit_tests/test_sheets.py index 5266533b9..14211f722 100644 --- a/libs/community/tests/unit_tests/test_sheets.py +++ b/libs/community/tests/unit_tests/test_sheets.py @@ -159,12 +159,12 @@ def test_sheets_toolkit_with_api_key() -> None: # Should return 3 read-only tools (no write tools with API key) assert len(tools) == 3 tool_names = [tool.name for tool in tools] - + # Read-only tools should be present assert "sheets_read_data" in tool_names assert "sheets_batch_read_data" in tool_names assert "sheets_get_spreadsheet_info" in tool_names - + # OAuth2-only tools should NOT be present assert "sheets_get_by_data_filter" not in tool_names assert "sheets_create_spreadsheet" not in tool_names