apply ruff formatting

This commit is contained in:
Taylor Wilsdon
2025-12-13 13:49:28 -08:00
parent 1d80a24ca4
commit 6b8352a354
50 changed files with 4010 additions and 2842 deletions

View File

@@ -11,8 +11,8 @@ from .validation_manager import ValidationManager
from .batch_operation_manager import BatchOperationManager
__all__ = [
'TableOperationManager',
'HeaderFooterManager',
'ValidationManager',
'BatchOperationManager'
]
"TableOperationManager",
"HeaderFooterManager",
"ValidationManager",
"BatchOperationManager",
]

View File

@@ -4,6 +4,7 @@ Batch Operation Manager
This module provides high-level batch operation management for Google Docs,
extracting complex validation and request building logic.
"""
import logging
import asyncio
from typing import Any, Union, Dict, List, Tuple
@@ -15,7 +16,7 @@ from gdocs.docs_helpers import (
create_find_replace_request,
create_insert_table_request,
create_insert_page_break_request,
validate_operation
validate_operation,
)
logger = logging.getLogger(__name__)
@@ -24,99 +25,106 @@ logger = logging.getLogger(__name__)
class BatchOperationManager:
"""
High-level manager for Google Docs batch operations.
Handles complex multi-operation requests including:
- Operation validation and request building
- Batch execution with proper error handling
- Operation result processing and reporting
"""
def __init__(self, service):
"""
Initialize the batch operation manager.
Args:
service: Google Docs API service instance
"""
self.service = service
async def execute_batch_operations(
self,
document_id: str,
operations: list[dict[str, Any]]
self, document_id: str, operations: list[dict[str, Any]]
) -> tuple[bool, str, dict[str, Any]]:
"""
Execute multiple document operations in a single atomic batch.
This method extracts the complex logic from batch_update_doc tool function.
Args:
document_id: ID of the document to update
operations: List of operation dictionaries
Returns:
Tuple of (success, message, metadata)
"""
logger.info(f"Executing batch operations on document {document_id}")
logger.info(f"Operations count: {len(operations)}")
if not operations:
return False, "No operations provided. Please provide at least one operation.", {}
return (
False,
"No operations provided. Please provide at least one operation.",
{},
)
try:
# Validate and build requests
requests, operation_descriptions = await self._validate_and_build_requests(operations)
requests, operation_descriptions = await self._validate_and_build_requests(
operations
)
if not requests:
return False, "No valid requests could be built from operations", {}
# Execute the batch
result = await self._execute_batch_requests(document_id, requests)
# Process results
metadata = {
'operations_count': len(operations),
'requests_count': len(requests),
'replies_count': len(result.get('replies', [])),
'operation_summary': operation_descriptions[:5] # First 5 operations
"operations_count": len(operations),
"requests_count": len(requests),
"replies_count": len(result.get("replies", [])),
"operation_summary": operation_descriptions[:5], # First 5 operations
}
summary = self._build_operation_summary(operation_descriptions)
return True, f"Successfully executed {len(operations)} operations ({summary})", metadata
return (
True,
f"Successfully executed {len(operations)} operations ({summary})",
metadata,
)
except Exception as e:
logger.error(f"Failed to execute batch operations: {str(e)}")
return False, f"Batch operation failed: {str(e)}", {}
async def _validate_and_build_requests(
self,
operations: list[dict[str, Any]]
self, operations: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], list[str]]:
"""
Validate operations and build API requests.
Args:
operations: List of operation dictionaries
Returns:
Tuple of (requests, operation_descriptions)
"""
requests = []
operation_descriptions = []
for i, op in enumerate(operations):
# Validate operation structure
is_valid, error_msg = validate_operation(op)
if not is_valid:
raise ValueError(f"Operation {i+1}: {error_msg}")
op_type = op.get('type')
raise ValueError(f"Operation {i + 1}: {error_msg}")
op_type = op.get("type")
try:
# Build request based on operation type
result = self._build_operation_request(op, op_type)
# Handle both single request and list of requests
if isinstance(result[0], list):
# Multiple requests (e.g., replace_text)
@@ -127,179 +135,211 @@ class BatchOperationManager:
# Single request
requests.append(result[0])
operation_descriptions.append(result[1])
except KeyError as e:
raise ValueError(f"Operation {i+1} ({op_type}) missing required field: {e}")
raise ValueError(
f"Operation {i + 1} ({op_type}) missing required field: {e}"
)
except Exception as e:
raise ValueError(f"Operation {i+1} ({op_type}) failed validation: {str(e)}")
raise ValueError(
f"Operation {i + 1} ({op_type}) failed validation: {str(e)}"
)
return requests, operation_descriptions
def _build_operation_request(
self,
op: dict[str, Any],
op_type: str
self, op: dict[str, Any], op_type: str
) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], str]:
"""
Build a single operation request.
Args:
op: Operation dictionary
op_type: Operation type
Returns:
Tuple of (request, description)
"""
if op_type == 'insert_text':
request = create_insert_text_request(op['index'], op['text'])
if op_type == "insert_text":
request = create_insert_text_request(op["index"], op["text"])
description = f"insert text at {op['index']}"
elif op_type == 'delete_text':
request = create_delete_range_request(op['start_index'], op['end_index'])
elif op_type == "delete_text":
request = create_delete_range_request(op["start_index"], op["end_index"])
description = f"delete text {op['start_index']}-{op['end_index']}"
elif op_type == 'replace_text':
elif op_type == "replace_text":
# Replace is delete + insert (must be done in this order)
delete_request = create_delete_range_request(op['start_index'], op['end_index'])
insert_request = create_insert_text_request(op['start_index'], op['text'])
delete_request = create_delete_range_request(
op["start_index"], op["end_index"]
)
insert_request = create_insert_text_request(op["start_index"], op["text"])
# Return both requests as a list
request = [delete_request, insert_request]
description = f"replace text {op['start_index']}-{op['end_index']} with '{op['text'][:20]}{'...' if len(op['text']) > 20 else ''}'"
elif op_type == 'format_text':
elif op_type == "format_text":
request = create_format_text_request(
op['start_index'], op['end_index'],
op.get('bold'), op.get('italic'), op.get('underline'),
op.get('font_size'), op.get('font_family'),
op.get('text_color'), op.get('background_color')
op["start_index"],
op["end_index"],
op.get("bold"),
op.get("italic"),
op.get("underline"),
op.get("font_size"),
op.get("font_family"),
op.get("text_color"),
op.get("background_color"),
)
if not request:
raise ValueError("No formatting options provided")
# Build format description
format_changes = []
for param, name in [
('bold', 'bold'), ('italic', 'italic'), ('underline', 'underline'),
('font_size', 'font size'), ('font_family', 'font family'),
('text_color', 'text color'), ('background_color', 'background color')
("bold", "bold"),
("italic", "italic"),
("underline", "underline"),
("font_size", "font size"),
("font_family", "font family"),
("text_color", "text color"),
("background_color", "background color"),
]:
if op.get(param) is not None:
value = f"{op[param]}pt" if param == 'font_size' else op[param]
value = f"{op[param]}pt" if param == "font_size" else op[param]
format_changes.append(f"{name}: {value}")
description = f"format text {op['start_index']}-{op['end_index']} ({', '.join(format_changes)})"
elif op_type == 'insert_table':
request = create_insert_table_request(op['index'], op['rows'], op['columns'])
elif op_type == "insert_table":
request = create_insert_table_request(
op["index"], op["rows"], op["columns"]
)
description = f"insert {op['rows']}x{op['columns']} table at {op['index']}"
elif op_type == 'insert_page_break':
request = create_insert_page_break_request(op['index'])
elif op_type == "insert_page_break":
request = create_insert_page_break_request(op["index"])
description = f"insert page break at {op['index']}"
elif op_type == 'find_replace':
elif op_type == "find_replace":
request = create_find_replace_request(
op['find_text'], op['replace_text'], op.get('match_case', False)
op["find_text"], op["replace_text"], op.get("match_case", False)
)
description = f"find/replace '{op['find_text']}''{op['replace_text']}'"
else:
supported_types = [
'insert_text', 'delete_text', 'replace_text', 'format_text',
'insert_table', 'insert_page_break', 'find_replace'
"insert_text",
"delete_text",
"replace_text",
"format_text",
"insert_table",
"insert_page_break",
"find_replace",
]
raise ValueError(f"Unsupported operation type '{op_type}'. Supported: {', '.join(supported_types)}")
raise ValueError(
f"Unsupported operation type '{op_type}'. Supported: {', '.join(supported_types)}"
)
return request, description
async def _execute_batch_requests(
self,
document_id: str,
requests: list[dict[str, Any]]
self, document_id: str, requests: list[dict[str, Any]]
) -> dict[str, Any]:
"""
Execute the batch requests against the Google Docs API.
Args:
document_id: Document ID
requests: List of API requests
Returns:
API response
"""
return await asyncio.to_thread(
self.service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
self.service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
def _build_operation_summary(self, operation_descriptions: list[str]) -> str:
"""
Build a concise summary of operations performed.
Args:
operation_descriptions: List of operation descriptions
Returns:
Summary string
"""
if not operation_descriptions:
return "no operations"
summary_items = operation_descriptions[:3] # Show first 3 operations
summary = ', '.join(summary_items)
summary = ", ".join(summary_items)
if len(operation_descriptions) > 3:
remaining = len(operation_descriptions) - 3
summary += f" and {remaining} more operation{'s' if remaining > 1 else ''}"
return summary
def get_supported_operations(self) -> dict[str, Any]:
"""
Get information about supported batch operations.
Returns:
Dictionary with supported operation types and their required parameters
"""
return {
'supported_operations': {
'insert_text': {
'required': ['index', 'text'],
'description': 'Insert text at specified index'
"supported_operations": {
"insert_text": {
"required": ["index", "text"],
"description": "Insert text at specified index",
},
'delete_text': {
'required': ['start_index', 'end_index'],
'description': 'Delete text in specified range'
"delete_text": {
"required": ["start_index", "end_index"],
"description": "Delete text in specified range",
},
'replace_text': {
'required': ['start_index', 'end_index', 'text'],
'description': 'Replace text in range with new text'
"replace_text": {
"required": ["start_index", "end_index", "text"],
"description": "Replace text in range with new text",
},
'format_text': {
'required': ['start_index', 'end_index'],
'optional': ['bold', 'italic', 'underline', 'font_size', 'font_family', 'text_color', 'background_color'],
'description': 'Apply formatting to text range'
"format_text": {
"required": ["start_index", "end_index"],
"optional": [
"bold",
"italic",
"underline",
"font_size",
"font_family",
"text_color",
"background_color",
],
"description": "Apply formatting to text range",
},
'insert_table': {
'required': ['index', 'rows', 'columns'],
'description': 'Insert table at specified index'
"insert_table": {
"required": ["index", "rows", "columns"],
"description": "Insert table at specified index",
},
'insert_page_break': {
'required': ['index'],
'description': 'Insert page break at specified index'
"insert_page_break": {
"required": ["index"],
"description": "Insert page break at specified index",
},
"find_replace": {
"required": ["find_text", "replace_text"],
"optional": ["match_case"],
"description": "Find and replace text throughout document",
},
'find_replace': {
'required': ['find_text', 'replace_text'],
'optional': ['match_case'],
'description': 'Find and replace text throughout document'
}
},
'example_operations': [
"example_operations": [
{"type": "insert_text", "index": 1, "text": "Hello World"},
{"type": "format_text", "start_index": 1, "end_index": 12, "bold": True},
{"type": "insert_table", "index": 20, "rows": 2, "columns": 3}
]
{
"type": "format_text",
"start_index": 1,
"end_index": 12,
"bold": True,
},
{"type": "insert_table", "index": 20, "rows": 2, "columns": 3},
],
}

View File

@@ -4,6 +4,7 @@ Header Footer Manager
This module provides high-level operations for managing headers and footers
in Google Docs, extracting complex logic from the main tools module.
"""
import logging
import asyncio
from typing import Any, Optional
@@ -14,319 +15,325 @@ logger = logging.getLogger(__name__)
class HeaderFooterManager:
"""
High-level manager for Google Docs header and footer operations.
Handles complex header/footer operations including:
- Finding and updating existing headers/footers
- Content replacement with proper range calculation
- Section type management
"""
def __init__(self, service):
"""
Initialize the header footer manager.
Args:
service: Google Docs API service instance
"""
self.service = service
async def update_header_footer_content(
self,
document_id: str,
section_type: str,
content: str,
header_footer_type: str = "DEFAULT"
header_footer_type: str = "DEFAULT",
) -> tuple[bool, str]:
"""
Updates header or footer content in a document.
This method extracts the complex logic from update_doc_headers_footers tool function.
Args:
document_id: ID of the document to update
section_type: Type of section ("header" or "footer")
content: New content for the section
header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE")
Returns:
Tuple of (success, message)
"""
logger.info(f"Updating {section_type} in document {document_id}")
# Validate section type
if section_type not in ["header", "footer"]:
return False, "section_type must be 'header' or 'footer'"
# Validate header/footer type
if header_footer_type not in ["DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE"]:
return False, "header_footer_type must be 'DEFAULT', 'FIRST_PAGE_ONLY', or 'EVEN_PAGE'"
return (
False,
"header_footer_type must be 'DEFAULT', 'FIRST_PAGE_ONLY', or 'EVEN_PAGE'",
)
try:
# Get document structure
doc = await self._get_document(document_id)
# Find the target section
target_section, section_id = await self._find_target_section(
doc, section_type, header_footer_type
)
if not target_section:
return False, f"No {section_type} found in document. Please create a {section_type} first in Google Docs."
return (
False,
f"No {section_type} found in document. Please create a {section_type} first in Google Docs.",
)
# Update the content
success = await self._replace_section_content(document_id, target_section, content)
success = await self._replace_section_content(
document_id, target_section, content
)
if success:
return True, f"Updated {section_type} content in document {document_id}"
else:
return False, f"Could not find content structure in {section_type} to update"
return (
False,
f"Could not find content structure in {section_type} to update",
)
except Exception as e:
logger.error(f"Failed to update {section_type}: {str(e)}")
return False, f"Failed to update {section_type}: {str(e)}"
async def _get_document(self, document_id: str) -> dict[str, Any]:
"""Get the full document data."""
return await asyncio.to_thread(
self.service.documents().get(documentId=document_id).execute
)
async def _find_target_section(
self,
doc: dict[str, Any],
section_type: str,
header_footer_type: str
self, doc: dict[str, Any], section_type: str, header_footer_type: str
) -> tuple[Optional[dict[str, Any]], Optional[str]]:
"""
Find the target header or footer section.
Args:
doc: Document data
section_type: "header" or "footer"
header_footer_type: Type of header/footer
Returns:
Tuple of (section_data, section_id) or (None, None) if not found
"""
if section_type == "header":
sections = doc.get('headers', {})
sections = doc.get("headers", {})
else:
sections = doc.get('footers', {})
sections = doc.get("footers", {})
# Try to match section based on header_footer_type
# Google Docs API typically uses section IDs that correspond to types
# First, try to find an exact match based on common patterns
for section_id, section_data in sections.items():
# Check if section_data contains type information
if 'type' in section_data and section_data['type'] == header_footer_type:
if "type" in section_data and section_data["type"] == header_footer_type:
return section_data, section_id
# If no exact match, try pattern matching on section ID
# Google Docs often uses predictable section ID patterns
target_patterns = {
"DEFAULT": ["default", "kix"], # DEFAULT headers often have these patterns
"FIRST_PAGE": ["first", "firstpage"],
"EVEN_PAGE": ["even", "evenpage"],
"FIRST_PAGE_ONLY": ["first", "firstpage"] # Legacy support
"FIRST_PAGE_ONLY": ["first", "firstpage"], # Legacy support
}
patterns = target_patterns.get(header_footer_type, [])
for pattern in patterns:
for section_id, section_data in sections.items():
if pattern.lower() in section_id.lower():
return section_data, section_id
# If still no match, return the first available section as fallback
# This maintains backward compatibility
for section_id, section_data in sections.items():
return section_data, section_id
return None, None
async def _replace_section_content(
self,
document_id: str,
section: dict[str, Any],
new_content: str
self, document_id: str, section: dict[str, Any], new_content: str
) -> bool:
"""
Replace the content in a header or footer section.
Args:
document_id: Document ID
section: Section data containing content elements
new_content: New content to insert
Returns:
True if successful, False otherwise
"""
content_elements = section.get('content', [])
content_elements = section.get("content", [])
if not content_elements:
return False
# Find the first paragraph to replace content
first_para = self._find_first_paragraph(content_elements)
if not first_para:
return False
# Calculate content range
start_index = first_para.get('startIndex', 0)
end_index = first_para.get('endIndex', 0)
start_index = first_para.get("startIndex", 0)
end_index = first_para.get("endIndex", 0)
# Build requests to replace content
requests = []
# Delete existing content if any (preserve paragraph structure)
if end_index > start_index:
requests.append({
'deleteContentRange': {
'range': {
'startIndex': start_index,
'endIndex': end_index - 1 # Keep the paragraph end marker
requests.append(
{
"deleteContentRange": {
"range": {
"startIndex": start_index,
"endIndex": end_index - 1, # Keep the paragraph end marker
}
}
}
})
)
# Insert new content
requests.append({
'insertText': {
'location': {'index': start_index},
'text': new_content
}
})
requests.append(
{"insertText": {"location": {"index": start_index}, "text": new_content}}
)
try:
await asyncio.to_thread(
self.service.documents().batchUpdate(
documentId=document_id,
body={'requests': requests}
).execute
self.service.documents()
.batchUpdate(documentId=document_id, body={"requests": requests})
.execute
)
return True
except Exception as e:
logger.error(f"Failed to replace section content: {str(e)}")
return False
def _find_first_paragraph(self, content_elements: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
def _find_first_paragraph(
self, content_elements: list[dict[str, Any]]
) -> Optional[dict[str, Any]]:
"""Find the first paragraph element in content."""
for element in content_elements:
if 'paragraph' in element:
if "paragraph" in element:
return element
return None
async def get_header_footer_info(
self,
document_id: str
) -> dict[str, Any]:
async def get_header_footer_info(self, document_id: str) -> dict[str, Any]:
"""
Get information about all headers and footers in the document.
Args:
document_id: Document ID
Returns:
Dictionary with header and footer information
"""
try:
doc = await self._get_document(document_id)
headers_info = {}
for header_id, header_data in doc.get('headers', {}).items():
for header_id, header_data in doc.get("headers", {}).items():
headers_info[header_id] = self._extract_section_info(header_data)
footers_info = {}
for footer_id, footer_data in doc.get('footers', {}).items():
for footer_id, footer_data in doc.get("footers", {}).items():
footers_info[footer_id] = self._extract_section_info(footer_data)
return {
'headers': headers_info,
'footers': footers_info,
'has_headers': bool(headers_info),
'has_footers': bool(footers_info)
"headers": headers_info,
"footers": footers_info,
"has_headers": bool(headers_info),
"has_footers": bool(footers_info),
}
except Exception as e:
logger.error(f"Failed to get header/footer info: {str(e)}")
return {'error': str(e)}
return {"error": str(e)}
def _extract_section_info(self, section_data: dict[str, Any]) -> dict[str, Any]:
"""Extract useful information from a header/footer section."""
content_elements = section_data.get('content', [])
content_elements = section_data.get("content", [])
# Extract text content
text_content = ""
for element in content_elements:
if 'paragraph' in element:
para = element['paragraph']
for para_element in para.get('elements', []):
if 'textRun' in para_element:
text_content += para_element['textRun'].get('content', '')
if "paragraph" in element:
para = element["paragraph"]
for para_element in para.get("elements", []):
if "textRun" in para_element:
text_content += para_element["textRun"].get("content", "")
return {
'content_preview': text_content[:100] if text_content else "(empty)",
'element_count': len(content_elements),
'start_index': content_elements[0].get('startIndex', 0) if content_elements else 0,
'end_index': content_elements[-1].get('endIndex', 0) if content_elements else 0
"content_preview": text_content[:100] if text_content else "(empty)",
"element_count": len(content_elements),
"start_index": content_elements[0].get("startIndex", 0)
if content_elements
else 0,
"end_index": content_elements[-1].get("endIndex", 0)
if content_elements
else 0,
}
async def create_header_footer(
self,
document_id: str,
section_type: str,
header_footer_type: str = "DEFAULT"
self, document_id: str, section_type: str, header_footer_type: str = "DEFAULT"
) -> tuple[bool, str]:
"""
Create a new header or footer section.
Args:
document_id: Document ID
section_type: "header" or "footer"
header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE", or "EVEN_PAGE")
Returns:
Tuple of (success, message)
"""
if section_type not in ["header", "footer"]:
return False, "section_type must be 'header' or 'footer'"
# Map our type names to API type names
type_mapping = {
"DEFAULT": "DEFAULT",
"FIRST_PAGE": "FIRST_PAGE",
"EVEN_PAGE": "EVEN_PAGE",
"FIRST_PAGE_ONLY": "FIRST_PAGE" # Support legacy name
"FIRST_PAGE_ONLY": "FIRST_PAGE", # Support legacy name
}
api_type = type_mapping.get(header_footer_type, header_footer_type)
if api_type not in ["DEFAULT", "FIRST_PAGE", "EVEN_PAGE"]:
return False, "header_footer_type must be 'DEFAULT', 'FIRST_PAGE', or 'EVEN_PAGE'"
return (
False,
"header_footer_type must be 'DEFAULT', 'FIRST_PAGE', or 'EVEN_PAGE'",
)
try:
# Build the request
request = {
'type': api_type
}
request = {"type": api_type}
# Create the appropriate request type
if section_type == "header":
batch_request = {'createHeader': request}
batch_request = {"createHeader": request}
else:
batch_request = {'createFooter': request}
batch_request = {"createFooter": request}
# Execute the request
await asyncio.to_thread(
self.service.documents().batchUpdate(
documentId=document_id,
body={'requests': [batch_request]}
).execute
self.service.documents()
.batchUpdate(documentId=document_id, body={"requests": [batch_request]})
.execute
)
return True, f"Successfully created {section_type} with type {api_type}"
except Exception as e:
error_msg = str(e)
if "already exists" in error_msg.lower():
return False, f"A {section_type} of type {api_type} already exists in the document"
return False, f"Failed to create {section_type}: {error_msg}"
return (
False,
f"A {section_type} of type {api_type} already exists in the document",
)
return False, f"Failed to create {section_type}: {error_msg}"

View File

@@ -4,6 +4,7 @@ Table Operation Manager
This module provides high-level table operations that orchestrate
multiple Google Docs API calls for complex table manipulations.
"""
import logging
import asyncio
from typing import List, Dict, Any, Tuple
@@ -18,153 +19,160 @@ logger = logging.getLogger(__name__)
class TableOperationManager:
"""
High-level manager for Google Docs table operations.
Handles complex multi-step table operations including:
- Creating tables with data population
- Populating existing tables
- Managing cell-by-cell operations with proper index refreshing
"""
def __init__(self, service):
"""
Initialize the table operation manager.
Args:
service: Google Docs API service instance
"""
self.service = service
async def create_and_populate_table(
self,
document_id: str,
table_data: List[List[str]],
index: int,
bold_headers: bool = True
bold_headers: bool = True,
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Creates a table and populates it with data in a reliable multi-step process.
This method extracts the complex logic from create_table_with_data tool function.
Args:
document_id: ID of the document to update
table_data: 2D list of strings for table content
index: Position to insert the table
bold_headers: Whether to make the first row bold
Returns:
Tuple of (success, message, metadata)
"""
logger.debug(f"Creating table at index {index}, dimensions: {len(table_data)}x{len(table_data[0]) if table_data and len(table_data) > 0 else 0}")
logger.debug(
f"Creating table at index {index}, dimensions: {len(table_data)}x{len(table_data[0]) if table_data and len(table_data) > 0 else 0}"
)
# Validate input data
is_valid, error_msg = validate_table_data(table_data)
if not is_valid:
return False, f"Invalid table data: {error_msg}", {}
rows = len(table_data)
cols = len(table_data[0])
try:
# Step 1: Create empty table
await self._create_empty_table(document_id, index, rows, cols)
# Step 2: Get fresh document structure to find actual cell positions
fresh_tables = await self._get_document_tables(document_id)
if not fresh_tables:
return False, "Could not find table after creation", {}
# Step 3: Populate each cell with proper index refreshing
population_count = await self._populate_table_cells(
document_id, table_data, bold_headers
)
metadata = {
'rows': rows,
'columns': cols,
'populated_cells': population_count,
'table_index': len(fresh_tables) - 1
"rows": rows,
"columns": cols,
"populated_cells": population_count,
"table_index": len(fresh_tables) - 1,
}
return True, f"Successfully created {rows}x{cols} table and populated {population_count} cells", metadata
return (
True,
f"Successfully created {rows}x{cols} table and populated {population_count} cells",
metadata,
)
except Exception as e:
logger.error(f"Failed to create and populate table: {str(e)}")
return False, f"Table creation failed: {str(e)}", {}
async def _create_empty_table(
self,
document_id: str,
index: int,
rows: int,
cols: int
self, document_id: str, index: int, rows: int, cols: int
) -> None:
"""Create an empty table at the specified index."""
logger.debug(f"Creating {rows}x{cols} table at index {index}")
await asyncio.to_thread(
self.service.documents().batchUpdate(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={'requests': [create_insert_table_request(index, rows, cols)]}
).execute
body={"requests": [create_insert_table_request(index, rows, cols)]},
)
.execute
)
async def _get_document_tables(self, document_id: str) -> List[Dict[str, Any]]:
"""Get fresh document structure and extract table information."""
doc = await asyncio.to_thread(
self.service.documents().get(documentId=document_id).execute
)
return find_tables(doc)
async def _populate_table_cells(
self,
document_id: str,
table_data: List[List[str]],
bold_headers: bool
self, document_id: str, table_data: List[List[str]], bold_headers: bool
) -> int:
"""
Populate table cells with data, refreshing structure after each insertion.
This prevents index shifting issues by getting fresh cell positions
before each insertion.
"""
population_count = 0
for row_idx, row_data in enumerate(table_data):
logger.debug(f"Processing row {row_idx}: {len(row_data)} cells")
for col_idx, cell_text in enumerate(row_data):
if not cell_text: # Skip empty cells
continue
try:
# CRITICAL: Refresh document structure before each insertion
success = await self._populate_single_cell(
document_id, row_idx, col_idx, cell_text, bold_headers and row_idx == 0
document_id,
row_idx,
col_idx,
cell_text,
bold_headers and row_idx == 0,
)
if success:
population_count += 1
logger.debug(f"Populated cell ({row_idx},{col_idx})")
else:
logger.warning(f"Failed to populate cell ({row_idx},{col_idx})")
except Exception as e:
logger.error(f"Error populating cell ({row_idx},{col_idx}): {str(e)}")
logger.error(
f"Error populating cell ({row_idx},{col_idx}): {str(e)}"
)
return population_count
async def _populate_single_cell(
self,
document_id: str,
row_idx: int,
col_idx: int,
cell_text: str,
apply_bold: bool = False
apply_bold: bool = False,
) -> bool:
"""
Populate a single cell with text, with optional bold formatting.
Returns True if successful, False otherwise.
"""
try:
@@ -172,167 +180,193 @@ class TableOperationManager:
tables = await self._get_document_tables(document_id)
if not tables:
return False
table = tables[-1] # Use the last table (newly created one)
cells = table.get('cells', [])
cells = table.get("cells", [])
# Bounds checking
if row_idx >= len(cells) or col_idx >= len(cells[row_idx]):
logger.error(f"Cell ({row_idx},{col_idx}) out of bounds")
return False
cell = cells[row_idx][col_idx]
insertion_index = cell.get('insertion_index')
insertion_index = cell.get("insertion_index")
if not insertion_index:
logger.warning(f"No insertion_index for cell ({row_idx},{col_idx})")
return False
# Insert text
await asyncio.to_thread(
self.service.documents().batchUpdate(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={'requests': [{
'insertText': {
'location': {'index': insertion_index},
'text': cell_text
}
}]}
).execute
body={
"requests": [
{
"insertText": {
"location": {"index": insertion_index},
"text": cell_text,
}
}
]
},
)
.execute
)
# Apply bold formatting if requested
if apply_bold:
await self._apply_bold_formatting(
document_id, insertion_index, insertion_index + len(cell_text)
)
return True
except Exception as e:
logger.error(f"Failed to populate single cell: {str(e)}")
return False
async def _apply_bold_formatting(
self,
document_id: str,
start_index: int,
end_index: int
self, document_id: str, start_index: int, end_index: int
) -> None:
"""Apply bold formatting to a text range."""
await asyncio.to_thread(
self.service.documents().batchUpdate(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={'requests': [{
'updateTextStyle': {
'range': {
'startIndex': start_index,
'endIndex': end_index
},
'textStyle': {'bold': True},
'fields': 'bold'
}
}]}
).execute
body={
"requests": [
{
"updateTextStyle": {
"range": {
"startIndex": start_index,
"endIndex": end_index,
},
"textStyle": {"bold": True},
"fields": "bold",
}
}
]
},
)
.execute
)
async def populate_existing_table(
self,
document_id: str,
table_index: int,
table_data: List[List[str]],
clear_existing: bool = False
clear_existing: bool = False,
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Populate an existing table with data.
Args:
document_id: ID of the document
table_index: Index of the table to populate (0-based)
table_data: 2D list of data to insert
clear_existing: Whether to clear existing content first
Returns:
Tuple of (success, message, metadata)
"""
try:
tables = await self._get_document_tables(document_id)
if table_index >= len(tables):
return False, f"Table index {table_index} not found. Document has {len(tables)} tables", {}
return (
False,
f"Table index {table_index} not found. Document has {len(tables)} tables",
{},
)
table_info = tables[table_index]
# Validate dimensions
table_rows = table_info['rows']
table_cols = table_info['columns']
table_rows = table_info["rows"]
table_cols = table_info["columns"]
data_rows = len(table_data)
data_cols = len(table_data[0]) if table_data else 0
if data_rows > table_rows or data_cols > table_cols:
return False, f"Data ({data_rows}x{data_cols}) exceeds table dimensions ({table_rows}x{table_cols})", {}
return (
False,
f"Data ({data_rows}x{data_cols}) exceeds table dimensions ({table_rows}x{table_cols})",
{},
)
# Populate cells
population_count = await self._populate_existing_table_cells(
document_id, table_index, table_data
)
metadata = {
'table_index': table_index,
'populated_cells': population_count,
'table_dimensions': f"{table_rows}x{table_cols}",
'data_dimensions': f"{data_rows}x{data_cols}"
"table_index": table_index,
"populated_cells": population_count,
"table_dimensions": f"{table_rows}x{table_cols}",
"data_dimensions": f"{data_rows}x{data_cols}",
}
return True, f"Successfully populated {population_count} cells in existing table", metadata
return (
True,
f"Successfully populated {population_count} cells in existing table",
metadata,
)
except Exception as e:
return False, f"Failed to populate existing table: {str(e)}", {}
async def _populate_existing_table_cells(
self,
document_id: str,
table_index: int,
table_data: List[List[str]]
self, document_id: str, table_index: int, table_data: List[List[str]]
) -> int:
"""Populate cells in an existing table."""
population_count = 0
for row_idx, row_data in enumerate(table_data):
for col_idx, cell_text in enumerate(row_data):
if not cell_text:
continue
# Get fresh table structure for each cell
tables = await self._get_document_tables(document_id)
if table_index >= len(tables):
break
table = tables[table_index]
cells = table.get('cells', [])
cells = table.get("cells", [])
if row_idx >= len(cells) or col_idx >= len(cells[row_idx]):
continue
cell = cells[row_idx][col_idx]
# For existing tables, append to existing content
cell_end = cell['end_index'] - 1 # Don't include cell end marker
cell_end = cell["end_index"] - 1 # Don't include cell end marker
try:
await asyncio.to_thread(
self.service.documents().batchUpdate(
self.service.documents()
.batchUpdate(
documentId=document_id,
body={'requests': [{
'insertText': {
'location': {'index': cell_end},
'text': cell_text
}
}]}
).execute
body={
"requests": [
{
"insertText": {
"location": {"index": cell_end},
"text": cell_text,
}
}
]
},
)
.execute
)
population_count += 1
except Exception as e:
logger.error(f"Failed to populate existing cell ({row_idx},{col_idx}): {str(e)}")
return population_count
logger.error(
f"Failed to populate existing cell ({row_idx},{col_idx}): {str(e)}"
)
return population_count

View File

@@ -4,6 +4,7 @@ Validation Manager
This module provides centralized validation logic for Google Docs operations,
extracting validation patterns from individual tool functions.
"""
import logging
from typing import Dict, Any, List, Tuple, Optional
@@ -15,106 +16,138 @@ logger = logging.getLogger(__name__)
class ValidationManager:
"""
Centralized validation manager for Google Docs operations.
Provides consistent validation patterns and error messages across
all document operations, reducing code duplication and improving
error message quality.
"""
def __init__(self):
"""Initialize the validation manager."""
self.validation_rules = self._setup_validation_rules()
def _setup_validation_rules(self) -> Dict[str, Any]:
"""Setup validation rules and constraints."""
return {
'table_max_rows': 1000,
'table_max_columns': 20,
'document_id_pattern': r'^[a-zA-Z0-9-_]+$',
'max_text_length': 1000000, # 1MB text limit
'font_size_range': (1, 400), # Google Docs font size limits
'valid_header_footer_types': ["DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE"],
'valid_section_types': ["header", "footer"],
'valid_list_types': ["UNORDERED", "ORDERED"],
'valid_element_types': ["table", "list", "page_break"]
"table_max_rows": 1000,
"table_max_columns": 20,
"document_id_pattern": r"^[a-zA-Z0-9-_]+$",
"max_text_length": 1000000, # 1MB text limit
"font_size_range": (1, 400), # Google Docs font size limits
"valid_header_footer_types": ["DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE"],
"valid_section_types": ["header", "footer"],
"valid_list_types": ["UNORDERED", "ORDERED"],
"valid_element_types": ["table", "list", "page_break"],
}
def validate_document_id(self, document_id: str) -> Tuple[bool, str]:
"""
Validate Google Docs document ID format.
Args:
document_id: Document ID to validate
Returns:
Tuple of (is_valid, error_message)
"""
if not document_id:
return False, "Document ID cannot be empty"
if not isinstance(document_id, str):
return False, f"Document ID must be a string, got {type(document_id).__name__}"
return (
False,
f"Document ID must be a string, got {type(document_id).__name__}",
)
# Basic length check (Google Docs IDs are typically 40+ characters)
if len(document_id) < 20:
return False, "Document ID appears too short to be valid"
return True, ""
def validate_table_data(self, table_data: List[List[str]]) -> Tuple[bool, str]:
"""
Comprehensive validation for table data format.
This extracts and centralizes table validation logic from multiple functions.
Args:
table_data: 2D array of data to validate
Returns:
Tuple of (is_valid, detailed_error_message)
"""
if not table_data:
return False, "Table data cannot be empty. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]"
return (
False,
"Table data cannot be empty. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]",
)
if not isinstance(table_data, list):
return False, f"Table data must be a list, got {type(table_data).__name__}. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]"
return (
False,
f"Table data must be a list, got {type(table_data).__name__}. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]",
)
# Check if it's a 2D list
if not all(isinstance(row, list) for row in table_data):
non_list_rows = [i for i, row in enumerate(table_data) if not isinstance(row, list)]
return False, f"All rows must be lists. Rows {non_list_rows} are not lists. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]"
non_list_rows = [
i for i, row in enumerate(table_data) if not isinstance(row, list)
]
return (
False,
f"All rows must be lists. Rows {non_list_rows} are not lists. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]",
)
# Check for empty rows
if any(len(row) == 0 for row in table_data):
empty_rows = [i for i, row in enumerate(table_data) if len(row) == 0]
return False, f"Rows cannot be empty. Empty rows found at indices: {empty_rows}"
return (
False,
f"Rows cannot be empty. Empty rows found at indices: {empty_rows}",
)
# Check column consistency
col_counts = [len(row) for row in table_data]
if len(set(col_counts)) > 1:
return False, f"All rows must have the same number of columns. Found column counts: {col_counts}. Fix your data structure."
return (
False,
f"All rows must have the same number of columns. Found column counts: {col_counts}. Fix your data structure.",
)
rows = len(table_data)
cols = col_counts[0]
# Check dimension limits
if rows > self.validation_rules['table_max_rows']:
return False, f"Too many rows ({rows}). Maximum allowed: {self.validation_rules['table_max_rows']}"
if cols > self.validation_rules['table_max_columns']:
return False, f"Too many columns ({cols}). Maximum allowed: {self.validation_rules['table_max_columns']}"
if rows > self.validation_rules["table_max_rows"]:
return (
False,
f"Too many rows ({rows}). Maximum allowed: {self.validation_rules['table_max_rows']}",
)
if cols > self.validation_rules["table_max_columns"]:
return (
False,
f"Too many columns ({cols}). Maximum allowed: {self.validation_rules['table_max_columns']}",
)
# Check cell content types
for row_idx, row in enumerate(table_data):
for col_idx, cell in enumerate(row):
if cell is None:
return False, f"Cell ({row_idx},{col_idx}) is None. All cells must be strings, use empty string '' for empty cells."
return (
False,
f"Cell ({row_idx},{col_idx}) is None. All cells must be strings, use empty string '' for empty cells.",
)
if not isinstance(cell, str):
return False, f"Cell ({row_idx},{col_idx}) is {type(cell).__name__}, not string. All cells must be strings. Value: {repr(cell)}"
return (
False,
f"Cell ({row_idx},{col_idx}) is {type(cell).__name__}, not string. All cells must be strings. Value: {repr(cell)}",
)
return True, f"Valid table data: {rows}×{cols} table format"
def validate_text_formatting_params(
self,
bold: Optional[bool] = None,
@@ -123,11 +156,11 @@ class ValidationManager:
font_size: Optional[int] = None,
font_family: Optional[str] = None,
text_color: Optional[Any] = None,
background_color: Optional[Any] = None
background_color: Optional[Any] = None,
) -> Tuple[bool, str]:
"""
Validate text formatting parameters.
Args:
bold: Bold setting
italic: Italic setting
@@ -136,36 +169,61 @@ class ValidationManager:
font_family: Font family name
text_color: Text color in hex or RGB tuple/list
background_color: Background color in hex or RGB tuple/list
Returns:
Tuple of (is_valid, error_message)
"""
# Check if at least one formatting option is provided
formatting_params = [
bold, italic, underline, font_size, font_family, text_color, background_color
bold,
italic,
underline,
font_size,
font_family,
text_color,
background_color,
]
if all(param is None for param in formatting_params):
return False, "At least one formatting parameter must be provided (bold, italic, underline, font_size, font_family, text_color, or background_color)"
return (
False,
"At least one formatting parameter must be provided (bold, italic, underline, font_size, font_family, text_color, or background_color)",
)
# Validate boolean parameters
for param, name in [(bold, 'bold'), (italic, 'italic'), (underline, 'underline')]:
for param, name in [
(bold, "bold"),
(italic, "italic"),
(underline, "underline"),
]:
if param is not None and not isinstance(param, bool):
return False, f"{name} parameter must be boolean (True/False), got {type(param).__name__}"
return (
False,
f"{name} parameter must be boolean (True/False), got {type(param).__name__}",
)
# Validate font size
if font_size is not None:
if not isinstance(font_size, int):
return False, f"font_size must be an integer, got {type(font_size).__name__}"
min_size, max_size = self.validation_rules['font_size_range']
return (
False,
f"font_size must be an integer, got {type(font_size).__name__}",
)
min_size, max_size = self.validation_rules["font_size_range"]
if not (min_size <= font_size <= max_size):
return False, f"font_size must be between {min_size} and {max_size} points, got {font_size}"
return (
False,
f"font_size must be between {min_size} and {max_size} points, got {font_size}",
)
# Validate font family
if font_family is not None:
if not isinstance(font_family, str):
return False, f"font_family must be a string, got {type(font_family).__name__}"
return (
False,
f"font_family must be a string, got {type(font_family).__name__}",
)
if not font_family.strip():
return False, "font_family cannot be empty"
@@ -174,10 +232,12 @@ class ValidationManager:
if not is_valid:
return False, error_msg
is_valid, error_msg = self.validate_color_param(background_color, "background_color")
is_valid, error_msg = self.validate_color_param(
background_color, "background_color"
)
if not is_valid:
return False, error_msg
return True, ""
def validate_color_param(self, color: Any, param_name: str) -> Tuple[bool, str]:
@@ -188,9 +248,14 @@ class ValidationManager:
return True, ""
if isinstance(color, str):
hex_color = color.lstrip('#')
if len(hex_color) != 6 or any(c not in "0123456789abcdefABCDEF" for c in hex_color):
return False, f"{param_name} must be a hex string like '#RRGGBB' or 'RRGGBB'"
hex_color = color.lstrip("#")
if len(hex_color) != 6 or any(
c not in "0123456789abcdefABCDEF" for c in hex_color
):
return (
False,
f"{param_name} must be a hex string like '#RRGGBB' or 'RRGGBB'",
)
return True, ""
if isinstance(color, (list, tuple)):
@@ -204,252 +269,305 @@ class ValidationManager:
if isinstance(component, int):
if component < 0 or component > 255:
return False, f"{comp_name} integer values must be between 0 and 255"
return (
False,
f"{comp_name} integer values must be between 0 and 255",
)
elif isinstance(component, float):
if component < 0 or component > 1:
return False, f"{comp_name} float values must be between 0 and 1"
return (
False,
f"{comp_name} float values must be between 0 and 1",
)
else:
return False, f"{comp_name} must be an int (0-255) or float (0-1), got {type(component).__name__}"
return (
False,
f"{comp_name} must be an int (0-255) or float (0-1), got {type(component).__name__}",
)
return True, ""
return False, f"{param_name} must be a hex string or RGB tuple/list like [255, 0, 0] or [1, 0, 0]"
return (
False,
f"{param_name} must be a hex string or RGB tuple/list like [255, 0, 0] or [1, 0, 0]",
)
def validate_index(self, index: int, context: str = "Index") -> Tuple[bool, str]:
"""
Validate a single document index.
Args:
index: Index to validate
context: Context description for error messages
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(index, int):
return False, f"{context} must be an integer, got {type(index).__name__}"
if index < 0:
return False, f"{context} {index} is negative. You MUST call inspect_doc_structure first to get the proper insertion index."
return (
False,
f"{context} {index} is negative. You MUST call inspect_doc_structure first to get the proper insertion index.",
)
return True, ""
def validate_index_range(
self,
start_index: int,
end_index: Optional[int] = None,
document_length: Optional[int] = None
document_length: Optional[int] = None,
) -> Tuple[bool, str]:
"""
Validate document index ranges.
Args:
start_index: Starting index
end_index: Ending index (optional)
document_length: Total document length for bounds checking
Returns:
Tuple of (is_valid, error_message)
"""
# Validate start_index
if not isinstance(start_index, int):
return False, f"start_index must be an integer, got {type(start_index).__name__}"
return (
False,
f"start_index must be an integer, got {type(start_index).__name__}",
)
if start_index < 0:
return False, f"start_index cannot be negative, got {start_index}"
# Validate end_index if provided
if end_index is not None:
if not isinstance(end_index, int):
return False, f"end_index must be an integer, got {type(end_index).__name__}"
return (
False,
f"end_index must be an integer, got {type(end_index).__name__}",
)
if end_index <= start_index:
return False, f"end_index ({end_index}) must be greater than start_index ({start_index})"
return (
False,
f"end_index ({end_index}) must be greater than start_index ({start_index})",
)
# Validate against document length if provided
if document_length is not None:
if start_index >= document_length:
return False, f"start_index ({start_index}) exceeds document length ({document_length})"
return (
False,
f"start_index ({start_index}) exceeds document length ({document_length})",
)
if end_index is not None and end_index > document_length:
return False, f"end_index ({end_index}) exceeds document length ({document_length})"
return (
False,
f"end_index ({end_index}) exceeds document length ({document_length})",
)
return True, ""
def validate_element_insertion_params(
self,
element_type: str,
index: int,
**kwargs
self, element_type: str, index: int, **kwargs
) -> Tuple[bool, str]:
"""
Validate parameters for element insertion.
Args:
element_type: Type of element to insert
index: Insertion index
**kwargs: Additional parameters specific to element type
Returns:
Tuple of (is_valid, error_message)
"""
# Validate element type
if element_type not in self.validation_rules['valid_element_types']:
valid_types = ', '.join(self.validation_rules['valid_element_types'])
return False, f"Invalid element_type '{element_type}'. Must be one of: {valid_types}"
if element_type not in self.validation_rules["valid_element_types"]:
valid_types = ", ".join(self.validation_rules["valid_element_types"])
return (
False,
f"Invalid element_type '{element_type}'. Must be one of: {valid_types}",
)
# Validate index
if not isinstance(index, int) or index < 0:
return False, f"index must be a non-negative integer, got {index}"
# Validate element-specific parameters
if element_type == "table":
rows = kwargs.get('rows')
columns = kwargs.get('columns')
rows = kwargs.get("rows")
columns = kwargs.get("columns")
if not rows or not columns:
return False, "Table insertion requires 'rows' and 'columns' parameters"
if not isinstance(rows, int) or not isinstance(columns, int):
return False, "Table rows and columns must be integers"
if rows <= 0 or columns <= 0:
return False, "Table rows and columns must be positive integers"
if rows > self.validation_rules['table_max_rows']:
return False, f"Too many rows ({rows}). Maximum: {self.validation_rules['table_max_rows']}"
if columns > self.validation_rules['table_max_columns']:
return False, f"Too many columns ({columns}). Maximum: {self.validation_rules['table_max_columns']}"
if rows > self.validation_rules["table_max_rows"]:
return (
False,
f"Too many rows ({rows}). Maximum: {self.validation_rules['table_max_rows']}",
)
if columns > self.validation_rules["table_max_columns"]:
return (
False,
f"Too many columns ({columns}). Maximum: {self.validation_rules['table_max_columns']}",
)
elif element_type == "list":
list_type = kwargs.get('list_type')
list_type = kwargs.get("list_type")
if not list_type:
return False, "List insertion requires 'list_type' parameter"
if list_type not in self.validation_rules['valid_list_types']:
valid_types = ', '.join(self.validation_rules['valid_list_types'])
return False, f"Invalid list_type '{list_type}'. Must be one of: {valid_types}"
if list_type not in self.validation_rules["valid_list_types"]:
valid_types = ", ".join(self.validation_rules["valid_list_types"])
return (
False,
f"Invalid list_type '{list_type}'. Must be one of: {valid_types}",
)
return True, ""
def validate_header_footer_params(
self,
section_type: str,
header_footer_type: str = "DEFAULT"
self, section_type: str, header_footer_type: str = "DEFAULT"
) -> Tuple[bool, str]:
"""
Validate header/footer operation parameters.
Args:
section_type: Type of section ("header" or "footer")
header_footer_type: Specific header/footer type
Returns:
Tuple of (is_valid, error_message)
"""
if section_type not in self.validation_rules['valid_section_types']:
valid_types = ', '.join(self.validation_rules['valid_section_types'])
return False, f"section_type must be one of: {valid_types}, got '{section_type}'"
if header_footer_type not in self.validation_rules['valid_header_footer_types']:
valid_types = ', '.join(self.validation_rules['valid_header_footer_types'])
return False, f"header_footer_type must be one of: {valid_types}, got '{header_footer_type}'"
if section_type not in self.validation_rules["valid_section_types"]:
valid_types = ", ".join(self.validation_rules["valid_section_types"])
return (
False,
f"section_type must be one of: {valid_types}, got '{section_type}'",
)
if header_footer_type not in self.validation_rules["valid_header_footer_types"]:
valid_types = ", ".join(self.validation_rules["valid_header_footer_types"])
return (
False,
f"header_footer_type must be one of: {valid_types}, got '{header_footer_type}'",
)
return True, ""
def validate_batch_operations(self, operations: List[Dict[str, Any]]) -> Tuple[bool, str]:
def validate_batch_operations(
self, operations: List[Dict[str, Any]]
) -> Tuple[bool, str]:
"""
Validate a list of batch operations.
Args:
operations: List of operation dictionaries
Returns:
Tuple of (is_valid, error_message)
"""
if not operations:
return False, "Operations list cannot be empty"
if not isinstance(operations, list):
return False, f"Operations must be a list, got {type(operations).__name__}"
# Validate each operation
for i, op in enumerate(operations):
if not isinstance(op, dict):
return False, f"Operation {i+1} must be a dictionary, got {type(op).__name__}"
if 'type' not in op:
return False, f"Operation {i+1} missing required 'type' field"
return (
False,
f"Operation {i + 1} must be a dictionary, got {type(op).__name__}",
)
if "type" not in op:
return False, f"Operation {i + 1} missing required 'type' field"
# Validate required fields for the operation type
is_valid, error_msg = validate_operation(op)
if not is_valid:
return False, f"Operation {i+1}: {error_msg}"
return False, f"Operation {i + 1}: {error_msg}"
op_type = op['type']
op_type = op["type"]
if op_type == 'format_text':
if op_type == "format_text":
is_valid, error_msg = self.validate_text_formatting_params(
op.get('bold'),
op.get('italic'),
op.get('underline'),
op.get('font_size'),
op.get('font_family'),
op.get('text_color'),
op.get('background_color')
op.get("bold"),
op.get("italic"),
op.get("underline"),
op.get("font_size"),
op.get("font_family"),
op.get("text_color"),
op.get("background_color"),
)
if not is_valid:
return False, f"Operation {i+1} (format_text): {error_msg}"
return False, f"Operation {i + 1} (format_text): {error_msg}"
is_valid, error_msg = self.validate_index_range(
op['start_index'],
op['end_index']
op["start_index"], op["end_index"]
)
if not is_valid:
return False, f"Operation {i+1} (format_text): {error_msg}"
return False, f"Operation {i + 1} (format_text): {error_msg}"
return True, ""
def validate_text_content(self, text: str, max_length: Optional[int] = None) -> Tuple[bool, str]:
def validate_text_content(
self, text: str, max_length: Optional[int] = None
) -> Tuple[bool, str]:
"""
Validate text content for insertion.
Args:
text: Text to validate
max_length: Maximum allowed length
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(text, str):
return False, f"Text must be a string, got {type(text).__name__}"
max_len = max_length or self.validation_rules['max_text_length']
max_len = max_length or self.validation_rules["max_text_length"]
if len(text) > max_len:
return False, f"Text too long ({len(text)} characters). Maximum: {max_len}"
return True, ""
def get_validation_summary(self) -> Dict[str, Any]:
"""
Get a summary of all validation rules and constraints.
Returns:
Dictionary containing validation rules
"""
return {
'constraints': self.validation_rules.copy(),
'supported_operations': {
'table_operations': ['create_table', 'populate_table'],
'text_operations': ['insert_text', 'format_text', 'find_replace'],
'element_operations': ['insert_table', 'insert_list', 'insert_page_break'],
'header_footer_operations': ['update_header', 'update_footer']
"constraints": self.validation_rules.copy(),
"supported_operations": {
"table_operations": ["create_table", "populate_table"],
"text_operations": ["insert_text", "format_text", "find_replace"],
"element_operations": [
"insert_table",
"insert_list",
"insert_page_break",
],
"header_footer_operations": ["update_header", "update_footer"],
},
"data_formats": {
"table_data": "2D list of strings: [['col1', 'col2'], ['row1col1', 'row1col2']]",
"text_formatting": "Optional boolean/integer parameters for styling",
"document_indices": "Non-negative integers for position specification",
},
'data_formats': {
'table_data': "2D list of strings: [['col1', 'col2'], ['row1col1', 'row1col2']]",
'text_formatting': "Optional boolean/integer parameters for styling",
'document_indices': "Non-negative integers for position specification"
}
}