Coverage for src/dataknobs_data/pandas/batch_ops.py: 0%
194 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 18:57 -0500
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 18:57 -0500
1"""Batch operations for DataKnobs-Pandas integration."""
3import logging
4from dataclasses import dataclass
5from typing import Any, Callable, Dict, Generator, List, Optional, Union
7import pandas as pd
8import numpy as np
10from dataknobs_data.records import Record
11from dataknobs_data.query import Query
12from dataknobs_data.database import AsyncDatabase, SyncDatabase
13from .converter import DataFrameConverter, ConversionOptions
15logger = logging.getLogger(__name__)
18@dataclass
19class BatchConfig:
20 """Configuration for batch operations."""
21 chunk_size: int = 1000
22 parallel: bool = False
23 max_workers: int = 4
24 progress_callback: Optional[Callable[[int, int], None]] = None
25 error_handling: str = "raise" # "raise", "skip", "log"
26 memory_efficient: bool = True
28 def __post_init__(self):
29 """Validate configuration parameters."""
30 if self.chunk_size <= 0:
31 raise ValueError("chunk_size must be greater than 0")
33 if self.error_handling not in ("raise", "skip", "log"):
34 raise ValueError("error_handling must be one of: 'raise', 'skip', 'log'")
37class ChunkedProcessor:
38 """Process DataFrames in chunks for memory efficiency."""
40 def __init__(self, chunk_size: int = 1000):
41 """Initialize chunked processor.
43 Args:
44 chunk_size: Size of each chunk
45 """
46 self.chunk_size = chunk_size
48 def process_dataframe(
49 self,
50 df: pd.DataFrame,
51 processor: Callable[[pd.DataFrame], Any],
52 combine: Optional[Callable[[List[Any]], Any]] = None
53 ) -> Any:
54 """Process DataFrame in chunks.
56 Args:
57 df: DataFrame to process
58 processor: Function to process each chunk
59 combine: Function to combine results
61 Returns:
62 Combined results or list of chunk results
63 """
64 results = []
66 for chunk in self.iter_chunks(df):
67 result = processor(chunk)
68 results.append(result)
70 if combine:
71 return combine(results)
72 return results
74 def iter_chunks(self, df: pd.DataFrame) -> Generator[pd.DataFrame, None, None]:
75 """Iterate over DataFrame in chunks.
77 Args:
78 df: DataFrame to chunk
80 Yields:
81 DataFrame chunks
82 """
83 for start_idx in range(0, len(df), self.chunk_size):
84 end_idx = min(start_idx + self.chunk_size, len(df))
85 yield df.iloc[start_idx:end_idx]
87 def read_csv_chunked(
88 self,
89 filepath: str,
90 processor: Callable[[pd.DataFrame], Any],
91 **read_kwargs
92 ) -> List[Any]:
93 """Read CSV file in chunks and process.
95 Args:
96 filepath: Path to CSV file
97 processor: Function to process each chunk
98 **read_kwargs: Additional arguments for pd.read_csv
100 Returns:
101 List of processed results
102 """
103 results = []
105 for chunk in pd.read_csv(filepath, chunksize=self.chunk_size, **read_kwargs):
106 result = processor(chunk)
107 results.append(result)
109 return results
112class BatchOperations:
113 """Batch operations for DataKnobs databases using DataFrames."""
115 def __init__(
116 self,
117 database: Union[AsyncDatabase, SyncDatabase],
118 converter: Optional[DataFrameConverter] = None
119 ):
120 """Initialize batch operations.
122 Args:
123 database: Target database
124 converter: DataFrame converter
125 """
126 self.database = database
127 self.converter = converter or DataFrameConverter()
128 self.is_async = hasattr(database, 'create') and asyncio.iscoroutinefunction(database.create)
130 def bulk_insert_dataframe(
131 self,
132 df: pd.DataFrame,
133 config: Optional[BatchConfig] = None,
134 conversion_options: Optional[ConversionOptions] = None
135 ) -> Dict[str, Any]:
136 """Bulk insert DataFrame rows into database.
138 Args:
139 df: DataFrame to insert
140 config: Batch configuration
141 conversion_options: Options for DataFrame conversion
143 Returns:
144 Insert statistics
145 """
146 config = config or BatchConfig()
147 conversion_options = conversion_options or ConversionOptions()
149 stats = {
150 "total_rows": len(df),
151 "inserted": 0,
152 "failed": 0,
153 "errors": []
154 }
156 # Process in chunks if memory efficient mode
157 if config.memory_efficient and len(df) > config.chunk_size:
158 processor = ChunkedProcessor(config.chunk_size)
160 def process_chunk(chunk_df: pd.DataFrame) -> Dict[str, int]:
161 return self._insert_chunk(chunk_df, config, conversion_options)
163 chunk_results = processor.process_dataframe(df, process_chunk)
165 # Aggregate results
166 for result in chunk_results:
167 stats["inserted"] += result["inserted"]
168 stats["failed"] += result["failed"]
169 if "errors" in result:
170 stats["errors"].extend(result["errors"])
171 else:
172 # Process entire DataFrame at once
173 stats = self._insert_chunk(df, config, conversion_options)
175 return stats
177 def query_as_dataframe(
178 self,
179 query: Query,
180 conversion_options: Optional[ConversionOptions] = None
181 ) -> pd.DataFrame:
182 """Execute query and return results as DataFrame.
184 Args:
185 query: Query to execute
186 conversion_options: Options for conversion
188 Returns:
189 Query results as DataFrame
190 """
191 conversion_options = conversion_options or ConversionOptions()
193 # Execute query
194 if self.is_async:
195 import asyncio
196 records = asyncio.run(self.database.search(query))
197 else:
198 records = self.database.search(query)
200 # Convert to DataFrame
201 return self.converter.records_to_dataframe(records, conversion_options)
203 def update_from_dataframe(
204 self,
205 df: pd.DataFrame,
206 id_column: str,
207 config: Optional[BatchConfig] = None,
208 conversion_options: Optional[ConversionOptions] = None
209 ) -> Dict[str, Any]:
210 """Update records from DataFrame using ID column.
212 Args:
213 df: DataFrame with updates
214 id_column: Column containing record IDs
215 config: Batch configuration
216 conversion_options: Conversion options
218 Returns:
219 Update statistics
220 """
221 config = config or BatchConfig()
222 conversion_options = conversion_options or ConversionOptions()
224 stats = {
225 "total_rows": len(df),
226 "updated": 0,
227 "failed": 0,
228 "not_found": 0,
229 "errors": []
230 }
232 # Ensure ID column exists
233 if id_column not in df.columns:
234 raise ValueError(f"ID column '{id_column}' not found in DataFrame")
236 # Convert DataFrame to records
237 records = self.converter.dataframe_to_records(df, conversion_options)
239 # Prepare updates as (id, record) tuples
240 updates = []
241 for i, record in enumerate(records):
242 record_id = str(df.iloc[i][id_column])
243 updates.append((record_id, record))
245 # Process updates in chunks
246 for i in range(0, len(updates), config.chunk_size):
247 chunk = updates[i:i + config.chunk_size]
249 try:
250 # Use batch update for better performance
251 if self.is_async:
252 import asyncio
253 results = asyncio.run(self.database.update_batch(chunk))
254 else:
255 results = self.database.update_batch(chunk)
257 # Count successes and failures
258 for success in results:
259 if success:
260 stats["updated"] += 1
261 else:
262 stats["not_found"] += 1
264 except Exception as e:
265 # If batch fails, try individual updates
266 if config.error_handling == "raise":
267 raise
269 for record_id, record in chunk:
270 try:
271 if self.is_async:
272 import asyncio
273 success = asyncio.run(self.database.update(record_id, record))
274 else:
275 success = self.database.update(record_id, record)
277 if success:
278 stats["updated"] += 1
279 else:
280 stats["not_found"] += 1
282 except Exception as e:
283 stats["failed"] += 1
284 if config.error_handling == "log":
285 logger.error(f"Failed to update record {record_id}: {e}")
286 stats["errors"].append(str(e))
287 # else "skip"
289 # Progress callback
290 if config.progress_callback:
291 processed = stats["updated"] + stats["failed"] + stats["not_found"]
292 config.progress_callback(processed, len(updates))
294 return stats
296 def aggregate(
297 self,
298 query: Query,
299 aggregations: Dict[str, Union[str, Callable]],
300 group_by: Optional[List[str]] = None
301 ) -> pd.DataFrame:
302 """Perform aggregations on query results.
304 Args:
305 query: Query to execute
306 aggregations: Dictionary of column: aggregation function
307 group_by: Columns to group by
309 Returns:
310 Aggregated DataFrame
311 """
312 # Get data as DataFrame
313 df = self.query_as_dataframe(query)
315 if df.empty:
316 return pd.DataFrame()
318 # Perform aggregations
319 if group_by:
320 grouped = df.groupby(group_by)
321 return grouped.agg(aggregations)
322 else:
323 # Single row with aggregations
324 result = {}
325 for col, agg_func in aggregations.items():
326 if col in df.columns:
327 if isinstance(agg_func, str):
328 result[f"{col}_{agg_func}"] = df[col].agg(agg_func)
329 else:
330 result[f"{col}_agg"] = agg_func(df[col])
331 return pd.DataFrame([result])
333 def transform_and_save(
334 self,
335 query: Query,
336 transformer: Callable[[pd.DataFrame], pd.DataFrame],
337 config: Optional[BatchConfig] = None
338 ) -> Dict[str, Any]:
339 """Query, transform with pandas, and save back.
341 Args:
342 query: Query to get records
343 transformer: Function to transform DataFrame
344 config: Batch configuration
346 Returns:
347 Operation statistics
348 """
349 config = config or BatchConfig()
351 # Get data
352 df = self.query_as_dataframe(query)
354 if df.empty:
355 return {"total_rows": 0, "transformed": 0}
357 # Apply transformation
358 transformed_df = transformer(df)
360 # Save back if index preserved (has record IDs)
361 if df.index.name == "record_id" and transformed_df.index.name == "record_id":
362 return self.update_from_dataframe(
363 transformed_df,
364 id_column=None, # Use index
365 config=config
366 )
367 else:
368 # Insert as new records
369 return self.bulk_insert_dataframe(transformed_df, config)
371 def _insert_chunk(
372 self,
373 df: pd.DataFrame,
374 config: BatchConfig,
375 conversion_options: ConversionOptions
376 ) -> Dict[str, Any]:
377 """Insert a chunk of DataFrame rows.
379 Args:
380 df: DataFrame chunk
381 config: Batch configuration
382 conversion_options: Conversion options
384 Returns:
385 Insert statistics for chunk
386 """
387 stats = {
388 "total_rows": len(df),
389 "inserted": 0,
390 "failed": 0,
391 "errors": []
392 }
394 # Convert to records
395 records = self.converter.dataframe_to_records(df, conversion_options)
397 # Use batch creation for better performance with graceful fallback
398 if hasattr(self.database, 'create_batch'):
399 try:
400 if self.is_async:
401 import asyncio
402 ids = asyncio.run(self.database.create_batch(records))
403 else:
404 ids = self.database.create_batch(records)
405 stats["inserted"] = len(ids)
407 # Progress callback for successful batch
408 if config.progress_callback:
409 config.progress_callback(len(records), len(records))
411 except Exception as batch_error:
412 # Batch failed, try individual records to identify failures
413 for i, record in enumerate(records):
414 try:
415 if self.is_async:
416 import asyncio
417 asyncio.run(self.database.create(record))
418 else:
419 self.database.create(record)
420 stats["inserted"] += 1
422 except Exception as record_error:
423 stats["failed"] += 1
425 # Handle error based on config
426 if config.error_handling == "raise":
427 raise
428 elif config.error_handling == "log":
429 logger.error(f"Failed to insert row {i}: {record_error}")
430 stats["errors"].append(str(record_error))
431 # else "skip" - just continue
433 # Progress callback for each record
434 if config.progress_callback:
435 config.progress_callback(i + 1, len(records))
436 else:
437 # Fallback to individual inserts if create_batch not available
438 for i, record in enumerate(records):
439 try:
440 if self.is_async:
441 import asyncio
442 asyncio.run(self.database.create(record))
443 else:
444 self.database.create(record)
445 stats["inserted"] += 1
447 except Exception as e:
448 stats["failed"] += 1
449 if config.error_handling == "raise":
450 raise
451 elif config.error_handling == "log":
452 logger.error(f"Failed to insert row {i}: {e}")
453 stats["errors"].append(str(e))
454 # else "skip"
456 # Progress callback
457 if config.progress_callback:
458 config.progress_callback(i + 1, len(records))
460 return stats
462 def export_to_csv(
463 self,
464 query: Query,
465 filepath: str,
466 conversion_options: Optional[ConversionOptions] = None,
467 **to_csv_kwargs
468 ) -> None:
469 """Export query results to CSV file.
471 Args:
472 query: Query to execute
473 filepath: Output file path
474 conversion_options: Conversion options
475 **to_csv_kwargs: Additional arguments for DataFrame.to_csv
476 """
477 df = self.query_as_dataframe(query, conversion_options)
478 df.to_csv(filepath, **to_csv_kwargs)
480 def export_to_parquet(
481 self,
482 query: Query,
483 filepath: str,
484 conversion_options: Optional[ConversionOptions] = None,
485 **to_parquet_kwargs
486 ) -> None:
487 """Export query results to Parquet file.
489 Args:
490 query: Query to execute
491 filepath: Output file path
492 conversion_options: Conversion options
493 **to_parquet_kwargs: Additional arguments for DataFrame.to_parquet
494 """
495 df = self.query_as_dataframe(query, conversion_options)
496 df.to_parquet(filepath, **to_parquet_kwargs)
499# Import asyncio only if needed
500try:
501 import asyncio
502except ImportError:
503 asyncio = None