Blablablab/audio-classification
0
1"""2Directory Watcher Module3 4This module provides functionality for loading annotation instances from a directory5and optionally watching that directory for new or modified files. When watching is6enabled, a background thread periodically scans the directory and dynamically loads7new instances or updates existing ones.8 9The module supports the same file formats as the standard data_files configuration:10JSON, JSONL, CSV, and TSV.11 12Configuration:13 data_directory: str - Path to the directory containing data files14 watch_data_directory: bool - Whether to watch for changes (default: False)15 watch_poll_interval: float - Seconds between directory scans (default: 5.0)16 data_directory_encoding: str - File encoding for directory files (default: "utf-8")17 18Example config:19 data_directory: "./data/incoming"20 watch_data_directory: true21 watch_poll_interval: 10.022"""23 24from __future__ import annotations25 26import json27import logging28import os29import threading30import glob31from dataclasses import dataclass, field32from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING33 34try:35 import pandas as pd36 HAS_PANDAS = True37except ImportError:38 HAS_PANDAS = False39 40if TYPE_CHECKING:41 from potato.item_state_management import ItemStateManager42 43logger = logging.getLogger(__name__)44 45# Singleton instance with thread-safe initialization46DIRECTORY_WATCHER: Optional['DirectoryWatcher'] = None47_DIRECTORY_WATCHER_LOCK = threading.Lock()48 49 50@dataclass51class FileState:52 """53 Tracks the state of a watched file.54 55 Attributes:56 file_path: Absolute path to the file57 last_modified: Last modification time (os.path.getmtime)58 file_size: File size in bytes59 instance_ids: Set of instance IDs loaded from this file60 last_error: Last error message if processing failed, None otherwise61 last_processed: Timestamp of last successful processing62 """63 file_path: str64 last_modified: float = 0.065 file_size: int = 066 instance_ids: Set[str] = field(default_factory=set)67 last_error: Optional[str] = None68 last_processed: Optional[float] = None69 70 71class DirectoryWatcher:72 """73 Watches a directory for new or modified data files and loads them as annotation instances.74 75 This class provides two modes of operation:76 1. Static loading: Load all files from a directory at startup (load_directory())77 2. Dynamic watching: Continuously monitor for changes (start_watching())78 79 The watcher tracks which instances came from which file, enabling proper handling80 of file modifications (updating existing instances rather than creating duplicates).81 82 Thread Safety:83 All public methods are thread-safe. The internal state is protected by84 a reentrant lock (_lock) to allow safe concurrent access from the main85 application thread and the background watching thread.86 87 Attributes:88 data_directory: Path to the directory to watch89 poll_interval: Seconds between directory scans90 id_key: Key in data items containing the unique instance ID91 text_key: Key in data items containing the text to annotate92 """93 94 # Supported file extensions95 SUPPORTED_EXTENSIONS = ('.json', '.jsonl', '.csv', '.tsv')96 97 def __init__(self, config: dict, item_state_manager: 'ItemStateManager'):98 """99 Initialize the directory watcher.100 101 Args:102 config: Configuration dictionary containing:103 - data_directory: Path to watch104 - watch_poll_interval: Seconds between scans (default: 5.0)105 - data_directory_encoding: File encoding (default: "utf-8")106 - item_properties.id_key: Key for instance IDs107 - item_properties.text_key: Key for text content108 item_state_manager: The ItemStateManager instance to add items to109 110 Raises:111 ValueError: If data_directory is not configured or doesn't exist112 """113 self.data_directory = config.get("data_directory")114 if not self.data_directory:115 raise ValueError("data_directory must be configured")116 117 # Resolve relative paths based on task_dir if available118 if not os.path.isabs(self.data_directory):119 task_dir = config.get("task_dir", "")120 if task_dir:121 self.data_directory = os.path.join(task_dir, self.data_directory)122 self.data_directory = os.path.abspath(self.data_directory)123 124 if not os.path.isdir(self.data_directory):125 raise ValueError(f"data_directory does not exist or is not a directory: {self.data_directory}")126 127 self.poll_interval = config.get("watch_poll_interval", 5.0)128 self.encoding = config.get("data_directory_encoding", "utf-8")129 self.id_key = config["item_properties"]["id_key"]130 self.text_key = config["item_properties"]["text_key"]131 132 self._item_state_manager = item_state_manager133 134 # File tracking state135 self._file_states: Dict[str, FileState] = {}136 self._instance_to_file: Dict[str, str] = {} # instance_id -> file_path137 138 # Threading139 self._lock = threading.RLock()140 self._stop_event = threading.Event()141 self._watch_thread: Optional[threading.Thread] = None142 143 logger.info(f"DirectoryWatcher initialized for: {self.data_directory}")144 145 def load_directory(self) -> int:146 """147 Load all supported files from the data directory.148 149 This method performs an initial scan of the directory and loads all150 instances from supported file formats. It should be called once at151 startup before start_watching().152 153 Returns:154 int: Total number of instances loaded155 156 Side Effects:157 - Populates ItemStateManager with loaded instances158 - Updates internal file tracking state159 """160 total_added = 0161 162 with self._lock:163 files = self._scan_directory()164 logger.info(f"Found {len(files)} supported files in {self.data_directory}")165 166 for file_path in files:167 try:168 added, updated = self._process_file(file_path)169 total_added += added170 if added > 0 or updated > 0:171 logger.info(f"Loaded {file_path}: {added} added, {updated} updated")172 except Exception as e:173 logger.error(f"Error loading {file_path}: {e}")174 175 logger.info(f"Directory load complete: {total_added} total instances loaded")176 return total_added177 178 def start_watching(self) -> None:179 """180 Start the background directory watching thread.181 182 The watching thread will periodically scan the directory for new or183 modified files and process them. Use stop() to terminate the thread.184 185 Note:186 This method is idempotent - calling it multiple times has no effect187 if the thread is already running.188 """189 with self._lock:190 if self._watch_thread is not None and self._watch_thread.is_alive():191 logger.warning("Directory watcher thread is already running")192 return193 194 self._stop_event.clear()195 self._watch_thread = threading.Thread(196 target=self._watch_loop,197 name="DirectoryWatcher",198 daemon=True199 )200 self._watch_thread.start()201 logger.info(f"Directory watching started (poll interval: {self.poll_interval}s)")202 203 def stop(self) -> None:204 """205 Stop the directory watching thread gracefully.206 207 This method signals the watching thread to stop and waits for it208 to terminate (up to 5 seconds). It's safe to call this method209 even if watching was never started.210 """211 self._stop_event.set()212 213 if self._watch_thread is not None and self._watch_thread.is_alive():214 self._watch_thread.join(timeout=5.0)215 if self._watch_thread.is_alive():216 logger.warning("Directory watcher thread did not stop gracefully")217 else:218 logger.info("Directory watcher stopped")219 220 self._watch_thread = None221 222 def get_stats(self) -> dict:223 """224 Get statistics about the directory watcher state.225 226 Returns:227 dict: Statistics including:228 - data_directory: Path being watched229 - is_watching: Whether the watch thread is running230 - poll_interval: Seconds between scans231 - files_tracked: Number of files being tracked232 - total_instances: Total instances loaded from this directory233 - files: List of file states with details234 """235 with self._lock:236 return {237 "data_directory": self.data_directory,238 "is_watching": self._watch_thread is not None and self._watch_thread.is_alive(),239 "poll_interval": self.poll_interval,240 "files_tracked": len(self._file_states),241 "total_instances": len(self._instance_to_file),242 "files": [243 {244 "path": fs.file_path,245 "last_modified": fs.last_modified,246 "instance_count": len(fs.instance_ids),247 "last_error": fs.last_error248 }249 for fs in self._file_states.values()250 ]251 }252 253 def force_rescan(self) -> Tuple[int, int]:254 """255 Force an immediate rescan of the directory.256 257 This method can be called to trigger an immediate check for changes258 without waiting for the next poll interval.259 260 Returns:261 Tuple[int, int]: (total_added, total_updated) counts262 """263 return self._scan_and_process()264 265 def _watch_loop(self) -> None:266 """267 Main watching loop that runs in the background thread.268 269 This loop periodically scans the directory for changes and processes270 any new or modified files. It continues until stop() is called.271 """272 logger.debug("Directory watch loop started")273 274 while not self._stop_event.is_set():275 try:276 added, updated = self._scan_and_process()277 if added > 0 or updated > 0:278 logger.info(f"Directory scan: {added} instances added, {updated} updated")279 except Exception as e:280 logger.error(f"Error in directory watch loop: {e}", exc_info=True)281 282 # Wait for the poll interval or until stopped283 self._stop_event.wait(timeout=self.poll_interval)284 285 logger.debug("Directory watch loop ended")286 287 def _scan_and_process(self) -> Tuple[int, int]:288 """289 Scan for changed files and process them.290 291 Returns:292 Tuple[int, int]: (total_added, total_updated) counts293 """294 total_added = 0295 total_updated = 0296 297 with self._lock:298 current_files = set(self._scan_directory())299 tracked_files = set(self._file_states.keys())300 301 # Find new and potentially modified files302 for file_path in current_files:303 try:304 stat = os.stat(file_path)305 current_mtime = stat.st_mtime306 current_size = stat.st_size307 except OSError as e:308 logger.warning(f"Cannot stat file {file_path}: {e}")309 continue310 311 # Check if file is new or modified312 if file_path not in self._file_states:313 # New file314 added, updated = self._process_file(file_path)315 total_added += added316 total_updated += updated317 else:318 # Check if modified319 fs = self._file_states[file_path]320 if current_mtime > fs.last_modified or current_size != fs.file_size:321 logger.debug(f"File modified: {file_path}")322 added, updated = self._process_file(file_path)323 total_added += added324 total_updated += updated325 326 # Note: We don't remove instances when files are deleted - this preserves327 # annotations that may have been made on those instances.328 removed_files = tracked_files - current_files329 for file_path in removed_files:330 logger.info(f"File removed (instances preserved): {file_path}")331 # Keep the file state but mark that the file is gone332 if file_path in self._file_states:333 self._file_states[file_path].last_error = "File removed from directory"334 335 return total_added, total_updated336 337 def _scan_directory(self) -> List[str]:338 """339 Scan the data directory for supported files.340 341 Returns:342 List[str]: List of absolute paths to supported files343 """344 files = []345 for ext in self.SUPPORTED_EXTENSIONS:346 pattern = os.path.join(self.data_directory, f"*{ext}")347 files.extend(glob.glob(pattern))348 return sorted(files)349 350 def _process_file(self, file_path: str) -> Tuple[int, int]:351 """352 Process a single data file, adding or updating instances.353 354 Args:355 file_path: Absolute path to the file to process356 357 Returns:358 Tuple[int, int]: (added_count, updated_count)359 360 Side Effects:361 - Updates ItemStateManager with new/updated instances362 - Updates file tracking state363 """364 added_count = 0365 updated_count = 0366 367 try:368 instances = self._parse_file(file_path)369 stat = os.stat(file_path)370 371 # Get or create file state372 if file_path not in self._file_states:373 self._file_states[file_path] = FileState(file_path=file_path)374 375 fs = self._file_states[file_path]376 new_instance_ids: Set[str] = set()377 378 for instance_data in instances:379 # Validate ID key exists380 if self.id_key not in instance_data:381 logger.warning(f"Missing id_key '{self.id_key}' in {file_path}, skipping instance")382 continue383 384 instance_id = str(instance_data[self.id_key])385 new_instance_ids.add(instance_id)386 387 # Check if text_key is missing (warning only)388 if self.text_key not in instance_data:389 logger.warning(f"Missing text_key '{self.text_key}' for instance {instance_id}")390 391 # Add or update the instance392 if self._item_state_manager.has_item(instance_id):393 # Update existing instance394 if self._item_state_manager.update_item(instance_id, instance_data):395 updated_count += 1396 logger.debug(f"Updated instance: {instance_id}")397 else:398 # Add new instance399 try:400 self._item_state_manager.add_item(instance_id, instance_data)401 self._instance_to_file[instance_id] = file_path402 added_count += 1403 logger.debug(f"Added instance: {instance_id}")404 except ValueError as e:405 logger.error(f"Failed to add instance {instance_id}: {e}")406 407 # Update file state408 fs.last_modified = stat.st_mtime409 fs.file_size = stat.st_size410 fs.instance_ids = new_instance_ids411 fs.last_error = None412 fs.last_processed = stat.st_mtime413 414 except Exception as e:415 logger.error(f"Error processing file {file_path}: {e}")416 if file_path in self._file_states:417 self._file_states[file_path].last_error = str(e)418 else:419 self._file_states[file_path] = FileState(420 file_path=file_path,421 last_error=str(e)422 )423 424 return added_count, updated_count425 426 def _parse_file(self, file_path: str) -> List[dict]:427 """428 Parse a data file and return a list of instance dictionaries.429 430 Args:431 file_path: Absolute path to the file432 433 Returns:434 List[dict]: List of instance data dictionaries435 436 Raises:437 ValueError: If file format is unsupported or parsing fails438 """439 ext = os.path.splitext(file_path)[1].lower()440 441 if ext in ('.json', '.jsonl'):442 return self._parse_json_file(file_path)443 elif ext == '.csv':444 return self._parse_csv_file(file_path, separator=',')445 elif ext == '.tsv':446 return self._parse_csv_file(file_path, separator='\t')447 else:448 raise ValueError(f"Unsupported file format: {ext}")449 450 def _parse_json_file(self, file_path: str) -> List[dict]:451 """452 Parse a JSON or JSONL file.453 454 Supports both:455 - JSONL format: One JSON object per line456 - JSON format: Single JSON array or object per line457 458 Args:459 file_path: Path to the JSON/JSONL file460 461 Returns:462 List[dict]: List of parsed instance dictionaries463 """464 instances = []465 466 with open(file_path, 'rt', encoding=self.encoding) as f:467 for line_no, line in enumerate(f, 1):468 line = line.strip()469 if not line:470 continue471 472 try:473 item = json.loads(line)474 475 # Handle both single objects and arrays476 if isinstance(item, list):477 instances.extend(item)478 else:479 instances.append(item)480 481 except json.JSONDecodeError as e:482 raise ValueError(483 f"Invalid JSON at line {line_no} in {file_path}: {e}"484 ) from e485 486 return instances487 488 def _parse_csv_file(self, file_path: str, separator: str) -> List[dict]:489 """490 Parse a CSV or TSV file.491 492 Args:493 file_path: Path to the CSV/TSV file494 separator: Column separator (',' for CSV, '\t' for TSV)495 496 Returns:497 List[dict]: List of row dictionaries498 499 Raises:500 ImportError: If pandas is not available501 ValueError: If required columns are missing502 """503 if not HAS_PANDAS:504 raise ImportError(505 "pandas is required for CSV/TSV file support. "506 "Install it with: pip install pandas"507 )508 509 df = pd.read_csv(file_path, sep=separator, encoding=self.encoding)510 511 # Validate ID column exists512 if self.id_key not in df.columns:513 raise ValueError(f"ID column '{self.id_key}' not found in {file_path}")514 515 # Convert ID column to string516 df[self.id_key] = df[self.id_key].astype(str)517 518 # Convert text column to string if present519 if self.text_key in df.columns:520 df[self.text_key] = df[self.text_key].astype(str)521 522 return df.to_dict('records')523 524 525def init_directory_watcher(config: dict) -> Optional[DirectoryWatcher]:526 """527 Initialize the global DirectoryWatcher singleton if data_directory is configured.528 529 This function creates a DirectoryWatcher instance if the configuration includes530 a data_directory setting. The watcher is initialized but not started - call531 load_directory() and optionally start_watching() after initialization.532 533 Args:534 config: Configuration dictionary535 536 Returns:537 DirectoryWatcher: The initialized watcher, or None if not configured538 539 Note:540 Thread-safe initialization using double-checked locking pattern.541 """542 global DIRECTORY_WATCHER543 544 # Check if data_directory is configured545 if "data_directory" not in config:546 return None547 548 # Double-checked locking for thread safety549 if DIRECTORY_WATCHER is None:550 with _DIRECTORY_WATCHER_LOCK:551 if DIRECTORY_WATCHER is None:552 from potato.item_state_management import get_item_state_manager553 ism = get_item_state_manager()554 DIRECTORY_WATCHER = DirectoryWatcher(config, ism)555 556 return DIRECTORY_WATCHER557 558 559def get_directory_watcher() -> Optional[DirectoryWatcher]:560 """561 Get the global DirectoryWatcher singleton instance.562 563 Returns:564 DirectoryWatcher: The singleton instance, or None if not initialized565 """566 return DIRECTORY_WATCHER567 568 569def clear_directory_watcher() -> None:570 """571 Clear the global DirectoryWatcher singleton (for testing).572 573 This function stops any running watch thread and clears the global instance.574 Thread-safe.575 """576 global DIRECTORY_WATCHER577 578 with _DIRECTORY_WATCHER_LOCK:579 if DIRECTORY_WATCHER is not None:580 DIRECTORY_WATCHER.stop()581 DIRECTORY_WATCHER = None582 