refactor tools to consolidate all modify actions

This commit is contained in:
Taylor Wilsdon
2026-03-01 12:36:09 -05:00
parent 7cd46d72c7
commit 6753531e9d
12 changed files with 1712 additions and 1924 deletions

View File

@@ -729,405 +729,420 @@ async def format_sheet_range(
@server.tool()
@handle_http_errors("add_conditional_formatting", service_type="sheets")
@handle_http_errors("manage_conditional_formatting", service_type="sheets")
@require_google_service("sheets", "sheets_write")
async def add_conditional_formatting(
async def manage_conditional_formatting(
service,
user_google_email: str,
spreadsheet_id: str,
range_name: str,
condition_type: str,
condition_values: Optional[Union[str, List[Union[str, int, float]]]] = None,
background_color: Optional[str] = None,
text_color: Optional[str] = None,
rule_index: Optional[int] = None,
gradient_points: Optional[Union[str, List[dict]]] = None,
) -> str:
"""
Adds a conditional formatting rule to a range.
Args:
user_google_email (str): The user's Google email address. Required.
spreadsheet_id (str): The ID of the spreadsheet. Required.
range_name (str): A1-style range (optionally with sheet name). Required.
condition_type (str): Sheets condition type (e.g., NUMBER_GREATER, TEXT_CONTAINS, DATE_BEFORE, CUSTOM_FORMULA).
condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values for the condition; accepts a list or a JSON string representing a list. Depends on condition_type.
background_color (Optional[str]): Hex background color to apply when condition matches.
text_color (Optional[str]): Hex text color to apply when condition matches.
rule_index (Optional[int]): Optional position to insert the rule (0-based) within the sheet's rules.
gradient_points (Optional[Union[str, List[dict]]]): List (or JSON list) of gradient points for a color scale. If provided, a gradient rule is created and boolean parameters are ignored.
Returns:
str: Confirmation of the added rule.
"""
logger.info(
"[add_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Range: %s, Type: %s, Values: %s",
user_google_email,
spreadsheet_id,
range_name,
condition_type,
condition_values,
)
if rule_index is not None and (not isinstance(rule_index, int) or rule_index < 0):
raise UserInputError("rule_index must be a non-negative integer when provided.")
condition_values_list = _parse_condition_values(condition_values)
gradient_points_list = _parse_gradient_points(gradient_points)
sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
grid_range = _parse_a1_range(range_name, sheets)
target_sheet = None
for sheet in sheets:
if sheet.get("properties", {}).get("sheetId") == grid_range.get("sheetId"):
target_sheet = sheet
break
if target_sheet is None:
raise UserInputError(
"Target sheet not found while adding conditional formatting."
)
current_rules = target_sheet.get("conditionalFormats", []) or []
insert_at = rule_index if rule_index is not None else len(current_rules)
if insert_at > len(current_rules):
raise UserInputError(
f"rule_index {insert_at} is out of range for sheet '{target_sheet.get('properties', {}).get('title', 'Unknown')}' "
f"(current count: {len(current_rules)})."
)
if gradient_points_list:
new_rule = _build_gradient_rule([grid_range], gradient_points_list)
rule_desc = "gradient"
values_desc = ""
applied_parts = [f"gradient points {len(gradient_points_list)}"]
else:
rule, cond_type_normalized = _build_boolean_rule(
[grid_range],
condition_type,
condition_values_list,
background_color,
text_color,
)
new_rule = rule
rule_desc = cond_type_normalized
values_desc = ""
if condition_values_list:
values_desc = f" with values {condition_values_list}"
applied_parts = []
if background_color:
applied_parts.append(f"background {background_color}")
if text_color:
applied_parts.append(f"text {text_color}")
new_rules_state = copy.deepcopy(current_rules)
new_rules_state.insert(insert_at, new_rule)
add_rule_request = {"rule": new_rule}
if rule_index is not None:
add_rule_request["index"] = rule_index
request_body = {"requests": [{"addConditionalFormatRule": add_rule_request}]}
await asyncio.to_thread(
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
.execute
)
format_desc = ", ".join(applied_parts) if applied_parts else "format applied"
sheet_title = target_sheet.get("properties", {}).get("title", "Unknown")
state_text = _format_conditional_rules_section(
sheet_title, new_rules_state, sheet_titles, indent=""
)
return "\n".join(
[
f"Added conditional format on '{range_name}' in spreadsheet {spreadsheet_id} "
f"for {user_google_email}: {rule_desc}{values_desc}; format: {format_desc}.",
state_text,
]
)
@server.tool()
@handle_http_errors("update_conditional_formatting", service_type="sheets")
@require_google_service("sheets", "sheets_write")
async def update_conditional_formatting(
service,
user_google_email: str,
spreadsheet_id: str,
rule_index: int,
action: str,
range_name: Optional[str] = None,
condition_type: Optional[str] = None,
condition_values: Optional[Union[str, List[Union[str, int, float]]]] = None,
background_color: Optional[str] = None,
text_color: Optional[str] = None,
sheet_name: Optional[str] = None,
rule_index: Optional[int] = None,
gradient_points: Optional[Union[str, List[dict]]] = None,
sheet_name: Optional[str] = None,
) -> str:
"""
Updates an existing conditional formatting rule by index on a sheet.
Manages conditional formatting rules on a Google Sheet. Supports adding,
updating, and deleting conditional formatting rules via a single tool.
Args:
user_google_email (str): The user's Google email address. Required.
spreadsheet_id (str): The ID of the spreadsheet. Required.
range_name (Optional[str]): A1-style range to apply the updated rule (optionally with sheet name). If omitted, existing ranges are preserved.
rule_index (int): Index of the rule to update (0-based).
condition_type (Optional[str]): Sheets condition type. If omitted, the existing rule's type is preserved.
condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values for the condition.
background_color (Optional[str]): Hex background color when condition matches.
text_color (Optional[str]): Hex text color when condition matches.
sheet_name (Optional[str]): Sheet name to locate the rule when range_name is omitted. Defaults to first sheet.
gradient_points (Optional[Union[str, List[dict]]]): If provided, updates the rule to a gradient color scale using these points.
action (str): The operation to perform. Must be one of "add", "update",
or "delete".
range_name (Optional[str]): A1-style range (optionally with sheet name).
Required for "add". Optional for "update" (preserves existing ranges
if omitted). Not used for "delete".
condition_type (Optional[str]): Sheets condition type (e.g., NUMBER_GREATER,
TEXT_CONTAINS, DATE_BEFORE, CUSTOM_FORMULA). Required for "add".
Optional for "update" (preserves existing type if omitted).
condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values
for the condition; accepts a list or a JSON string representing a list.
Depends on condition_type. Used by "add" and "update".
background_color (Optional[str]): Hex background color to apply when
condition matches. Used by "add" and "update".
text_color (Optional[str]): Hex text color to apply when condition matches.
Used by "add" and "update".
rule_index (Optional[int]): 0-based index of the rule. For "add", optionally
specifies insertion position. Required for "update" and "delete".
gradient_points (Optional[Union[str, List[dict]]]): List (or JSON list) of
gradient points for a color scale. If provided, a gradient rule is created
and boolean parameters are ignored. Used by "add" and "update".
sheet_name (Optional[str]): Sheet name to locate the rule when range_name is
omitted. Defaults to the first sheet. Used by "update" and "delete".
Returns:
str: Confirmation of the updated rule and the current rule state.
str: Confirmation of the operation and the current rule state.
"""
allowed_actions = {"add", "update", "delete"}
action_normalized = action.strip().lower()
if action_normalized not in allowed_actions:
raise UserInputError(
f"action must be one of {sorted(allowed_actions)}, got '{action}'."
)
logger.info(
"[update_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Range: %s, Rule Index: %s",
"[manage_conditional_formatting] Invoked. Action: '%s', Email: '%s', Spreadsheet: %s",
action_normalized,
user_google_email,
spreadsheet_id,
range_name,
rule_index,
)
if not isinstance(rule_index, int) or rule_index < 0:
raise UserInputError("rule_index must be a non-negative integer.")
if action_normalized == "add":
if not range_name:
raise UserInputError("range_name is required for action 'add'.")
if not condition_type and not gradient_points:
raise UserInputError(
"condition_type (or gradient_points) is required for action 'add'."
)
condition_values_list = _parse_condition_values(condition_values)
gradient_points_list = _parse_gradient_points(gradient_points)
if rule_index is not None and (
not isinstance(rule_index, int) or rule_index < 0
):
raise UserInputError(
"rule_index must be a non-negative integer when provided."
)
sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
condition_values_list = _parse_condition_values(condition_values)
gradient_points_list = _parse_gradient_points(gradient_points)
target_sheet = None
grid_range = None
if range_name:
sheets, sheet_titles = await _fetch_sheets_with_rules(
service, spreadsheet_id
)
grid_range = _parse_a1_range(range_name, sheets)
target_sheet = None
for sheet in sheets:
if sheet.get("properties", {}).get("sheetId") == grid_range.get("sheetId"):
if (
sheet.get("properties", {}).get("sheetId")
== grid_range.get("sheetId")
):
target_sheet = sheet
break
else:
if target_sheet is None:
raise UserInputError(
"Target sheet not found while adding conditional formatting."
)
current_rules = target_sheet.get("conditionalFormats", []) or []
insert_at = rule_index if rule_index is not None else len(current_rules)
if insert_at > len(current_rules):
raise UserInputError(
f"rule_index {insert_at} is out of range for sheet "
f"'{target_sheet.get('properties', {}).get('title', 'Unknown')}' "
f"(current count: {len(current_rules)})."
)
if gradient_points_list:
new_rule = _build_gradient_rule([grid_range], gradient_points_list)
rule_desc = "gradient"
values_desc = ""
applied_parts = [f"gradient points {len(gradient_points_list)}"]
else:
rule, cond_type_normalized = _build_boolean_rule(
[grid_range],
condition_type,
condition_values_list,
background_color,
text_color,
)
new_rule = rule
rule_desc = cond_type_normalized
values_desc = ""
if condition_values_list:
values_desc = f" with values {condition_values_list}"
applied_parts = []
if background_color:
applied_parts.append(f"background {background_color}")
if text_color:
applied_parts.append(f"text {text_color}")
new_rules_state = copy.deepcopy(current_rules)
new_rules_state.insert(insert_at, new_rule)
add_rule_request = {"rule": new_rule}
if rule_index is not None:
add_rule_request["index"] = rule_index
request_body = {
"requests": [{"addConditionalFormatRule": add_rule_request}]
}
await asyncio.to_thread(
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
.execute
)
format_desc = (
", ".join(applied_parts) if applied_parts else "format applied"
)
sheet_title = target_sheet.get("properties", {}).get("title", "Unknown")
state_text = _format_conditional_rules_section(
sheet_title, new_rules_state, sheet_titles, indent=""
)
return "\n".join(
[
f"Added conditional format on '{range_name}' in spreadsheet "
f"{spreadsheet_id} for {user_google_email}: "
f"{rule_desc}{values_desc}; format: {format_desc}.",
state_text,
]
)
elif action_normalized == "update":
if rule_index is None:
raise UserInputError("rule_index is required for action 'update'.")
if not isinstance(rule_index, int) or rule_index < 0:
raise UserInputError("rule_index must be a non-negative integer.")
condition_values_list = _parse_condition_values(condition_values)
gradient_points_list = _parse_gradient_points(gradient_points)
sheets, sheet_titles = await _fetch_sheets_with_rules(
service, spreadsheet_id
)
target_sheet = None
grid_range = None
if range_name:
grid_range = _parse_a1_range(range_name, sheets)
for sheet in sheets:
if (
sheet.get("properties", {}).get("sheetId")
== grid_range.get("sheetId")
):
target_sheet = sheet
break
else:
target_sheet = _select_sheet(sheets, sheet_name)
if target_sheet is None:
raise UserInputError(
"Target sheet not found while updating conditional formatting."
)
sheet_props = target_sheet.get("properties", {})
sheet_id = sheet_props.get("sheetId")
sheet_title = sheet_props.get("title", f"Sheet {sheet_id}")
rules = target_sheet.get("conditionalFormats", []) or []
if rule_index >= len(rules):
raise UserInputError(
f"rule_index {rule_index} is out of range for sheet "
f"'{sheet_title}' (current count: {len(rules)})."
)
existing_rule = rules[rule_index]
ranges_to_use = existing_rule.get("ranges", [])
if range_name:
ranges_to_use = [grid_range]
if not ranges_to_use:
ranges_to_use = [{"sheetId": sheet_id}]
new_rule = None
rule_desc = ""
values_desc = ""
format_desc = ""
if gradient_points_list is not None:
new_rule = _build_gradient_rule(ranges_to_use, gradient_points_list)
rule_desc = "gradient"
format_desc = f"gradient points {len(gradient_points_list)}"
elif "gradientRule" in existing_rule:
if any(
[
background_color,
text_color,
condition_type,
condition_values_list,
]
):
raise UserInputError(
"Existing rule is a gradient rule. Provide gradient_points "
"to update it, or omit formatting/condition parameters to "
"keep it unchanged."
)
new_rule = {
"ranges": ranges_to_use,
"gradientRule": existing_rule.get("gradientRule", {}),
}
rule_desc = "gradient"
format_desc = "gradient (unchanged)"
else:
existing_boolean = existing_rule.get("booleanRule", {})
existing_condition = existing_boolean.get("condition", {})
existing_format = copy.deepcopy(
existing_boolean.get("format", {})
)
cond_type = (
condition_type or existing_condition.get("type", "")
).upper()
if not cond_type:
raise UserInputError(
"condition_type is required for boolean rules."
)
if cond_type not in CONDITION_TYPES:
raise UserInputError(
f"condition_type must be one of {sorted(CONDITION_TYPES)}."
)
if condition_values_list is not None:
cond_values = [
{"userEnteredValue": str(val)}
for val in condition_values_list
]
else:
cond_values = existing_condition.get("values")
new_format = (
copy.deepcopy(existing_format) if existing_format else {}
)
if background_color is not None:
bg_color_parsed = _parse_hex_color(background_color)
if bg_color_parsed:
new_format["backgroundColor"] = bg_color_parsed
elif "backgroundColor" in new_format:
del new_format["backgroundColor"]
if text_color is not None:
text_color_parsed = _parse_hex_color(text_color)
text_format = copy.deepcopy(
new_format.get("textFormat", {})
)
if text_color_parsed:
text_format["foregroundColor"] = text_color_parsed
elif "foregroundColor" in text_format:
del text_format["foregroundColor"]
if text_format:
new_format["textFormat"] = text_format
elif "textFormat" in new_format:
del new_format["textFormat"]
if not new_format:
raise UserInputError(
"At least one format option must remain on the rule."
)
new_rule = {
"ranges": ranges_to_use,
"booleanRule": {
"condition": {"type": cond_type},
"format": new_format,
},
}
if cond_values:
new_rule["booleanRule"]["condition"]["values"] = cond_values
rule_desc = cond_type
if condition_values_list:
values_desc = f" with values {condition_values_list}"
format_parts = []
if "backgroundColor" in new_format:
format_parts.append("background updated")
if "textFormat" in new_format and new_format["textFormat"].get(
"foregroundColor"
):
format_parts.append("text color updated")
format_desc = (
", ".join(format_parts) if format_parts else "format preserved"
)
new_rules_state = copy.deepcopy(rules)
new_rules_state[rule_index] = new_rule
request_body = {
"requests": [
{
"updateConditionalFormatRule": {
"index": rule_index,
"sheetId": sheet_id,
"rule": new_rule,
}
}
]
}
await asyncio.to_thread(
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
.execute
)
state_text = _format_conditional_rules_section(
sheet_title, new_rules_state, sheet_titles, indent=""
)
return "\n".join(
[
f"Updated conditional format at index {rule_index} on sheet "
f"'{sheet_title}' in spreadsheet {spreadsheet_id} "
f"for {user_google_email}: "
f"{rule_desc}{values_desc}; format: {format_desc}.",
state_text,
]
)
else: # action_normalized == "delete"
if rule_index is None:
raise UserInputError("rule_index is required for action 'delete'.")
if not isinstance(rule_index, int) or rule_index < 0:
raise UserInputError("rule_index must be a non-negative integer.")
sheets, sheet_titles = await _fetch_sheets_with_rules(
service, spreadsheet_id
)
target_sheet = _select_sheet(sheets, sheet_name)
if target_sheet is None:
raise UserInputError(
"Target sheet not found while updating conditional formatting."
)
sheet_props = target_sheet.get("properties", {})
sheet_id = sheet_props.get("sheetId")
sheet_title = sheet_props.get("title", f"Sheet {sheet_id}")
rules = target_sheet.get("conditionalFormats", []) or []
if rule_index >= len(rules):
raise UserInputError(
f"rule_index {rule_index} is out of range for sheet '{sheet_title}' (current count: {len(rules)})."
)
existing_rule = rules[rule_index]
ranges_to_use = existing_rule.get("ranges", [])
if range_name:
ranges_to_use = [grid_range]
if not ranges_to_use:
ranges_to_use = [{"sheetId": sheet_id}]
new_rule = None
rule_desc = ""
values_desc = ""
format_desc = ""
if gradient_points_list is not None:
new_rule = _build_gradient_rule(ranges_to_use, gradient_points_list)
rule_desc = "gradient"
format_desc = f"gradient points {len(gradient_points_list)}"
elif "gradientRule" in existing_rule:
if any([background_color, text_color, condition_type, condition_values_list]):
sheet_props = target_sheet.get("properties", {})
sheet_id = sheet_props.get("sheetId")
target_sheet_name = sheet_props.get("title", f"Sheet {sheet_id}")
rules = target_sheet.get("conditionalFormats", []) or []
if rule_index >= len(rules):
raise UserInputError(
"Existing rule is a gradient rule. Provide gradient_points to update it, or omit formatting/condition parameters to keep it unchanged."
)
new_rule = {
"ranges": ranges_to_use,
"gradientRule": existing_rule.get("gradientRule", {}),
}
rule_desc = "gradient"
format_desc = "gradient (unchanged)"
else:
existing_boolean = existing_rule.get("booleanRule", {})
existing_condition = existing_boolean.get("condition", {})
existing_format = copy.deepcopy(existing_boolean.get("format", {}))
cond_type = (condition_type or existing_condition.get("type", "")).upper()
if not cond_type:
raise UserInputError("condition_type is required for boolean rules.")
if cond_type not in CONDITION_TYPES:
raise UserInputError(
f"condition_type must be one of {sorted(CONDITION_TYPES)}."
f"rule_index {rule_index} is out of range for sheet "
f"'{target_sheet_name}' (current count: {len(rules)})."
)
if condition_values_list is not None:
cond_values = [
{"userEnteredValue": str(val)} for val in condition_values_list
new_rules_state = copy.deepcopy(rules)
del new_rules_state[rule_index]
request_body = {
"requests": [
{
"deleteConditionalFormatRule": {
"index": rule_index,
"sheetId": sheet_id,
}
}
]
else:
cond_values = existing_condition.get("values")
new_format = copy.deepcopy(existing_format) if existing_format else {}
if background_color is not None:
bg_color_parsed = _parse_hex_color(background_color)
if bg_color_parsed:
new_format["backgroundColor"] = bg_color_parsed
elif "backgroundColor" in new_format:
del new_format["backgroundColor"]
if text_color is not None:
text_color_parsed = _parse_hex_color(text_color)
text_format = copy.deepcopy(new_format.get("textFormat", {}))
if text_color_parsed:
text_format["foregroundColor"] = text_color_parsed
elif "foregroundColor" in text_format:
del text_format["foregroundColor"]
if text_format:
new_format["textFormat"] = text_format
elif "textFormat" in new_format:
del new_format["textFormat"]
if not new_format:
raise UserInputError("At least one format option must remain on the rule.")
new_rule = {
"ranges": ranges_to_use,
"booleanRule": {
"condition": {"type": cond_type},
"format": new_format,
},
}
if cond_values:
new_rule["booleanRule"]["condition"]["values"] = cond_values
rule_desc = cond_type
if condition_values_list:
values_desc = f" with values {condition_values_list}"
format_parts = []
if "backgroundColor" in new_format:
format_parts.append("background updated")
if "textFormat" in new_format and new_format["textFormat"].get(
"foregroundColor"
):
format_parts.append("text color updated")
format_desc = ", ".join(format_parts) if format_parts else "format preserved"
new_rules_state = copy.deepcopy(rules)
new_rules_state[rule_index] = new_rule
request_body = {
"requests": [
{
"updateConditionalFormatRule": {
"index": rule_index,
"sheetId": sheet_id,
"rule": new_rule,
}
}
]
}
await asyncio.to_thread(
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
.execute
)
state_text = _format_conditional_rules_section(
sheet_title, new_rules_state, sheet_titles, indent=""
)
return "\n".join(
[
f"Updated conditional format at index {rule_index} on sheet '{sheet_title}' in spreadsheet {spreadsheet_id} "
f"for {user_google_email}: {rule_desc}{values_desc}; format: {format_desc}.",
state_text,
]
)
@server.tool()
@handle_http_errors("delete_conditional_formatting", service_type="sheets")
@require_google_service("sheets", "sheets_write")
async def delete_conditional_formatting(
service,
user_google_email: str,
spreadsheet_id: str,
rule_index: int,
sheet_name: Optional[str] = None,
) -> str:
"""
Deletes an existing conditional formatting rule by index on a sheet.
Args:
user_google_email (str): The user's Google email address. Required.
spreadsheet_id (str): The ID of the spreadsheet. Required.
rule_index (int): Index of the rule to delete (0-based).
sheet_name (Optional[str]): Name of the sheet that contains the rule. Defaults to the first sheet if not provided.
Returns:
str: Confirmation of the deletion and the current rule state.
"""
logger.info(
"[delete_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Sheet: %s, Rule Index: %s",
user_google_email,
spreadsheet_id,
sheet_name,
rule_index,
)
if not isinstance(rule_index, int) or rule_index < 0:
raise UserInputError("rule_index must be a non-negative integer.")
sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id)
target_sheet = _select_sheet(sheets, sheet_name)
sheet_props = target_sheet.get("properties", {})
sheet_id = sheet_props.get("sheetId")
target_sheet_name = sheet_props.get("title", f"Sheet {sheet_id}")
rules = target_sheet.get("conditionalFormats", []) or []
if rule_index >= len(rules):
raise UserInputError(
f"rule_index {rule_index} is out of range for sheet '{target_sheet_name}' (current count: {len(rules)})."
await asyncio.to_thread(
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
.execute
)
new_rules_state = copy.deepcopy(rules)
del new_rules_state[rule_index]
state_text = _format_conditional_rules_section(
target_sheet_name, new_rules_state, sheet_titles, indent=""
)
request_body = {
"requests": [
{
"deleteConditionalFormatRule": {
"index": rule_index,
"sheetId": sheet_id,
}
}
]
}
await asyncio.to_thread(
service.spreadsheets()
.batchUpdate(spreadsheetId=spreadsheet_id, body=request_body)
.execute
)
state_text = _format_conditional_rules_section(
target_sheet_name, new_rules_state, sheet_titles, indent=""
)
return "\n".join(
[
f"Deleted conditional format at index {rule_index} on sheet '{target_sheet_name}' in spreadsheet {spreadsheet_id} for {user_google_email}.",
state_text,
]
)
return "\n".join(
[
f"Deleted conditional format at index {rule_index} on sheet "
f"'{target_sheet_name}' in spreadsheet {spreadsheet_id} "
f"for {user_google_email}.",
state_text,
]
)
@server.tool()