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