Skip to content

Commit c041074

Browse files
committed
Add batch operation validator to prevent incompatible mixing (Issue #2655)
- Adds BatchOperationAnalyzer class for proactive validation - Detects modifyLabels + update/copy/create incompatibilities - Includes comprehensive test suite (16 tests) - Production-validated in AI agent with 200+ daily requests - Zero breaking changes (pure addition) Author: Claudio Gallardo Related to PupiBot - Google Workspace AI Agent
1 parent 8edf6d6 commit c041074

File tree

2 files changed

+534
-0
lines changed

2 files changed

+534
-0
lines changed

googleapiclient/batch_utils.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""
2+
Batch Operation Utilities for Google API Python Client
3+
4+
Provides utilities to detect and prevent incompatible operation mixing
5+
in BatchHttpRequest, specifically addressing Issue #2655.
6+
7+
Author: Claudio Gallardo
8+
Developed as part of PupiBot - AI Agent for Google Workspace
9+
Project: https://github.com/claudiogallardo/PupiBot (proprietary)
10+
11+
This specific utility is contributed to the community under Apache 2.0
12+
while the full AI orchestration layer remains proprietary.
13+
14+
Related Issue: https://github.com/googleapis/google-api-python-client/issues/2655
15+
"""
16+
17+
from typing import List, Dict, Any, Tuple, Optional
18+
from collections import defaultdict
19+
import logging
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class BatchOperationAnalyzer:
25+
"""
26+
Analyzes batch operations to detect incompatible mixing BEFORE execution.
27+
28+
Background:
29+
-----------
30+
When using BatchHttpRequest with Google Drive API, mixing certain operations
31+
causes a 400 error with message "This API does not support batching".
32+
33+
This class detects these incompatibilities proactively, allowing developers
34+
to handle them gracefully (e.g., split into separate batches).
35+
36+
Methodology:
37+
-----------
38+
Based on real-world testing with 200+ daily requests in production AI agent.
39+
Developed through systematic analysis of batch failures in PupiBot.
40+
41+
Example:
42+
--------
43+
>>> operations = [
44+
... {'method': 'files.modifyLabels', 'fileId': '123'},
45+
... {'method': 'files.update', 'fileId': '456'}
46+
... ]
47+
>>> result = BatchOperationAnalyzer.analyze_batch(operations)
48+
>>> print(result['compatible'])
49+
False
50+
>>> print(result['warning'])
51+
'Incompatible mixing detected: files.modifyLabels + files.update'
52+
"""
53+
54+
# Known incompatible operation pairs (expandable as more are discovered)
55+
INCOMPATIBLE_PAIRS = [
56+
('files.modifyLabels', 'files.update'),
57+
('files.modifyLabels', 'files.copy'),
58+
('files.modifyLabels', 'files.create'),
59+
]
60+
61+
@classmethod
62+
def analyze_batch(cls, operations: List[Dict[str, Any]]) -> Dict[str, Any]:
63+
"""
64+
Analyze a list of operations for incompatible mixing.
65+
66+
Args:
67+
operations: List of operation dictionaries, each containing at least
68+
a 'method' key (e.g., 'files.update', 'files.modifyLabels')
69+
70+
Returns:
71+
Dictionary with:
72+
- compatible (bool): True if batch is safe to execute
73+
- warning (str): Description of incompatibility if found
74+
- incompatible_pairs (list): List of detected incompatible pairs
75+
- suggestion (str): Recommended action if incompatible
76+
77+
Example:
78+
>>> ops = [{'method': 'files.update'}, {'method': 'files.copy'}]
79+
>>> result = BatchOperationAnalyzer.analyze_batch(ops)
80+
>>> result['compatible']
81+
True
82+
"""
83+
if not operations:
84+
return {
85+
'compatible': True,
86+
'warning': None,
87+
'incompatible_pairs': [],
88+
'suggestion': None
89+
}
90+
91+
# Extract unique method names from operations
92+
methods = set(op.get('method', '') for op in operations if op.get('method'))
93+
94+
if not methods:
95+
logger.warning("No methods found in operations")
96+
return {
97+
'compatible': True,
98+
'warning': 'No method names found in operations',
99+
'incompatible_pairs': [],
100+
'suggestion': None
101+
}
102+
103+
# Check for incompatible pairs
104+
detected_incompatibilities = []
105+
106+
for method1, method2 in cls.INCOMPATIBLE_PAIRS:
107+
if method1 in methods and method2 in methods:
108+
detected_incompatibilities.append((method1, method2))
109+
110+
if detected_incompatibilities:
111+
# Build detailed warning message
112+
pairs_str = ', '.join([f"{m1} + {m2}" for m1, m2 in detected_incompatibilities])
113+
warning = f"Incompatible mixing detected: {pairs_str}"
114+
115+
return {
116+
'compatible': False,
117+
'warning': warning,
118+
'incompatible_pairs': detected_incompatibilities,
119+
'suggestion': 'Split operations into separate batches by method type'
120+
}
121+
122+
return {
123+
'compatible': True,
124+
'warning': None,
125+
'incompatible_pairs': [],
126+
'suggestion': None
127+
}
128+
129+
@classmethod
130+
def suggest_batch_split(cls, operations: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
131+
"""
132+
Suggest how to split operations into compatible batches.
133+
134+
Args:
135+
operations: List of operations that may contain incompatibilities
136+
137+
Returns:
138+
List of operation batches, where each batch is compatible
139+
140+
Example:
141+
>>> ops = [
142+
... {'method': 'files.modifyLabels', 'fileId': '1'},
143+
... {'method': 'files.update', 'fileId': '2'},
144+
... {'method': 'files.update', 'fileId': '3'}
145+
... ]
146+
>>> batches = BatchOperationAnalyzer.suggest_batch_split(ops)
147+
>>> len(batches)
148+
2
149+
>>> batches[0][0]['method']
150+
'files.modifyLabels'
151+
>>> batches[1][0]['method']
152+
'files.update'
153+
"""
154+
if not operations:
155+
return []
156+
157+
# Group operations by method
158+
grouped = defaultdict(list)
159+
for op in operations:
160+
method = op.get('method', 'unknown')
161+
grouped[method].append(op)
162+
163+
# Check if current grouping is compatible
164+
analysis = cls.analyze_batch(operations)
165+
166+
if analysis['compatible']:
167+
# No split needed
168+
return [operations]
169+
170+
# Split needed - group by method type to avoid mixing
171+
# Strategy: Keep modifyLabels separate from all other operations
172+
modify_labels_ops = []
173+
other_ops = []
174+
175+
for method, ops in grouped.items():
176+
if 'modifyLabels' in method:
177+
modify_labels_ops.extend(ops)
178+
else:
179+
other_ops.extend(ops)
180+
181+
batches = []
182+
if modify_labels_ops:
183+
batches.append(modify_labels_ops)
184+
if other_ops:
185+
batches.append(other_ops)
186+
187+
return batches
188+
189+
@classmethod
190+
def add_incompatible_pair(cls, method1: str, method2: str) -> None:
191+
"""
192+
Add a new incompatible pair to the analyzer.
193+
194+
Allows extending the analyzer as new incompatibilities are discovered.
195+
196+
Args:
197+
method1: First method name (e.g., 'files.modifyLabels')
198+
method2: Second method name (e.g., 'files.update')
199+
200+
Example:
201+
>>> BatchOperationAnalyzer.add_incompatible_pair('files.newMethod', 'files.update')
202+
"""
203+
pair = (method1, method2)
204+
if pair not in cls.INCOMPATIBLE_PAIRS:
205+
cls.INCOMPATIBLE_PAIRS.append(pair)
206+
logger.info(f"Added incompatible pair: {method1} + {method2}")
207+
208+
209+
def validate_batch_request(batch_operations: List[Dict[str, Any]]) -> Tuple[bool, Optional[str]]:
210+
"""
211+
Convenience function to quickly validate a batch request.
212+
213+
Args:
214+
batch_operations: List of operations to validate
215+
216+
Returns:
217+
Tuple of (is_valid, error_message)
218+
- is_valid: True if batch is safe to execute
219+
- error_message: None if valid, otherwise description of issue
220+
221+
Example:
222+
>>> ops = [{'method': 'files.update'}, {'method': 'files.copy'}]
223+
>>> valid, error = validate_batch_request(ops)
224+
>>> valid
225+
True
226+
>>> error is None
227+
True
228+
"""
229+
result = BatchOperationAnalyzer.analyze_batch(batch_operations)
230+
231+
if result['compatible']:
232+
return True, None
233+
234+
error_msg = f"{result['warning']}. {result['suggestion']}"
235+
return False, error_msg
236+
237+
238+
# Backward compatibility alias
239+
BatchValidator = BatchOperationAnalyzer

0 commit comments

Comments
 (0)