feat: Implement comprehensive progress callback system

- Created callback interfaces (ProgressCallback, ErrorCallback, CompletionCallback)
- Defined rich context objects (ProgressContext, ErrorContext, CompletionContext)
- Implemented CallbackManager for managing multiple callbacks
- Integrated callbacks into SerieScanner for scan progress reporting
- Enhanced SeriesApp with download progress tracking via callbacks
- Added error and completion notifications throughout core operations
- Maintained backward compatibility with legacy callback system
- Created 22 comprehensive unit tests with 100% pass rate
- Updated infrastructure.md with callback system documentation
- Removed completed tasks from instructions.md

The callback system provides:
- Real-time progress updates with percentage and phase tracking
- Comprehensive error reporting with recovery information
- Operation completion notifications with statistics
- Thread-safe callback execution with exception handling
- Support for multiple simultaneous callbacks per type
This commit is contained in:
2025-10-17 20:05:57 +02:00
parent 59edf6bd50
commit a0f32b1a00
6 changed files with 1300 additions and 63 deletions

View File

@@ -1,59 +1,257 @@
"""
SerieScanner - Scans directories for anime series and missing episodes.
This module provides functionality to scan anime directories, identify
missing episodes, and report progress through callback interfaces.
"""
import logging
import os
import re
import logging
from .entities.series import Serie
import traceback
from ..infrastructure.logging.GlobalLogger import error_logger, noKeyFound_logger
from .exceptions.Exceptions import NoKeyFoundException, MatchNotFoundError
from .providers.base_provider import Loader
import uuid
from typing import Callable, Optional
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.core.providers.base_provider import Loader
from src.infrastructure.logging.GlobalLogger import error_logger, noKeyFound_logger
logger = logging.getLogger(__name__)
class SerieScanner:
def __init__(self, basePath: str, loader: Loader):
"""
Scans directories for anime series and identifies missing episodes.
Supports progress callbacks for real-time scanning updates.
"""
def __init__(
self,
basePath: str,
loader: Loader,
callback_manager: Optional[CallbackManager] = None
):
"""
Initialize the SerieScanner.
Args:
basePath: Base directory containing anime series
loader: Loader instance for fetching series information
callback_manager: Optional callback manager for progress updates
"""
self.directory = basePath
self.folderDict: dict[str, Serie] = {} # Proper initialization
self.folderDict: dict[str, Serie] = {}
self.loader = loader
logging.info(f"Initialized Loader with base path: {self.directory}")
self._callback_manager = callback_manager or CallbackManager()
self._current_operation_id: Optional[str] = None
logger.info("Initialized SerieScanner with base path: %s", basePath)
@property
def callback_manager(self) -> CallbackManager:
"""Get the callback manager instance."""
return self._callback_manager
def Reinit(self):
self.folderDict: dict[str, Serie] = {} # Proper initialization
"""Reinitialize the folder dictionary."""
self.folderDict: dict[str, Serie] = {}
def is_null_or_whitespace(self, s):
"""Check if a string is None or whitespace."""
return s is None or s.strip() == ""
def GetTotalToScan(self):
"""Get the total number of folders to scan."""
result = self.__find_mp4_files()
return sum(1 for _ in result)
def Scan(self, callback):
logging.info("Starting process to load missing episodes")
result = self.__find_mp4_files()
counter = 0
for folder, mp4_files in result:
try:
counter += 1
callback(folder, counter)
serie = self.__ReadDataFromFile(folder)
if (serie != None and not self.is_null_or_whitespace(serie.key)):
missings, site = self.__GetMissingEpisodesAndSeason(serie.key, mp4_files)
serie.episodeDict = missings
serie.folder = folder
serie.save_to_file(os.path.join(os.path.join(self.directory, folder), 'data'))
if (serie.key in self.folderDict):
logging.ERROR(f"dublication found: {serie.key}");
pass
self.folderDict[serie.key] = serie
noKeyFound_logger.info(f"Saved Serie: '{str(serie)}'")
except NoKeyFoundException as nkfe:
NoKeyFoundException.error(f"Error processing folder '{folder}': {nkfe}")
except Exception as e:
error_logger.error(f"Folder: '{folder}' - Unexpected error processing folder '{folder}': {e} \n {traceback.format_exc()}")
continue
def Scan(self, callback: Optional[Callable[[str, int], None]] = None):
"""
Scan directories for anime series and missing episodes.
Args:
callback: Optional legacy callback function (folder, count)
Raises:
Exception: If scan fails critically
"""
# Generate unique operation ID
self._current_operation_id = str(uuid.uuid4())
logger.info("Starting scan for missing episodes")
# Notify scan starting
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=0,
percentage=0.0,
message="Initializing scan"
)
)
try:
# Get total items to process
total_to_scan = self.GetTotalToScan()
logger.info("Total folders to scan: %d", total_to_scan)
result = self.__find_mp4_files()
counter = 0
for folder, mp4_files in result:
try:
counter += 1
# Calculate progress
percentage = (
(counter / total_to_scan * 100)
if total_to_scan > 0 else 0
)
# Notify progress
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=counter,
total=total_to_scan,
percentage=percentage,
message=f"Scanning: {folder}",
details=f"Found {len(mp4_files)} episodes"
)
)
# Call legacy callback if provided
if callback:
callback(folder, counter)
serie = self.__ReadDataFromFile(folder)
if (
serie is not None
and not self.is_null_or_whitespace(serie.key)
):
missings, site = self.__GetMissingEpisodesAndSeason(
serie.key, mp4_files
)
serie.episodeDict = missings
serie.folder = folder
data_path = os.path.join(
self.directory, folder, 'data'
)
serie.save_to_file(data_path)
if serie.key in self.folderDict:
logger.error(
"Duplication found: %s", serie.key
)
else:
self.folderDict[serie.key] = serie
noKeyFound_logger.info(
"Saved Serie: '%s'", str(serie)
)
except NoKeyFoundException as nkfe:
# Log error and notify via callback
error_msg = f"Error processing folder '{folder}': {nkfe}"
NoKeyFoundException.error(error_msg)
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=nkfe,
message=error_msg,
recoverable=True,
metadata={"folder": folder}
)
)
except Exception as e:
# Log error and notify via callback
error_msg = (
f"Folder: '{folder}' - "
f"Unexpected error: {e}"
)
error_logger.error(
"%s\n%s",
error_msg,
traceback.format_exc()
)
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=True,
metadata={"folder": folder}
)
)
continue
# Notify scan completion
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=True,
message=f"Scan completed. Processed {counter} folders.",
statistics={
"total_folders": counter,
"series_found": len(self.folderDict)
}
)
)
logger.info(
"Scan completed. Processed %d folders, found %d series",
counter,
len(self.folderDict)
)
except Exception as e:
# Critical error - notify and re-raise
error_msg = f"Critical scan error: {e}"
logger.error("%s\n%s", error_msg, traceback.format_exc())
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=False
)
)
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=False,
message=error_msg
)
)
raise
def __find_mp4_files(self):
logging.info("Scanning for .mp4 files")
"""Find all .mp4 files in the directory structure."""
logger.info("Scanning for .mp4 files")
for anime_name in os.listdir(self.directory):
anime_path = os.path.join(self.directory, anime_name)
if os.path.isdir(anime_path):
@@ -67,43 +265,68 @@ class SerieScanner:
yield anime_name, mp4_files if has_files else []
def __remove_year(self, input_string: str):
"""Remove year information from input string."""
cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip()
logging.debug(f"Removed year from '{input_string}' -> '{cleaned_string}'")
logger.debug(
"Removed year from '%s' -> '%s'",
input_string,
cleaned_string
)
return cleaned_string
def __ReadDataFromFile(self, folder_name: str):
"""Read serie data from file or key file."""
folder_path = os.path.join(self.directory, folder_name)
key = None
key_file = os.path.join(folder_path, 'key')
serie_file = os.path.join(folder_path, 'data')
if os.path.exists(key_file):
with open(key_file, 'r') as file:
with open(key_file, 'r', encoding='utf-8') as file:
key = file.read().strip()
logging.info(f"Key found for folder '{folder_name}': {key}")
logger.info(
"Key found for folder '%s': %s",
folder_name,
key
)
return Serie(key, "", "aniworld.to", folder_name, dict())
if os.path.exists(serie_file):
with open(serie_file, "rb") as file:
logging.info(f"load serie_file from '{folder_name}': {serie_file}")
logger.info(
"load serie_file from '%s': %s",
folder_name,
serie_file
)
return Serie.load_from_file(serie_file)
return None
def __GetEpisodeAndSeason(self, filename: str):
"""Extract season and episode numbers from filename."""
pattern = r'S(\d+)E(\d+)'
match = re.search(pattern, filename)
if match:
season = match.group(1)
episode = match.group(2)
logging.debug(f"Extracted season {season}, episode {episode} from '{filename}'")
logger.debug(
"Extracted season %s, episode %s from '%s'",
season,
episode,
filename
)
return int(season), int(episode)
else:
logging.error(f"Failed to find season/episode pattern in '{filename}'")
raise MatchNotFoundError("Season and episode pattern not found in the filename.")
logger.error(
"Failed to find season/episode pattern in '%s'",
filename
)
raise MatchNotFoundError(
"Season and episode pattern not found in the filename."
)
def __GetEpisodesAndSeasons(self, mp4_files: []):
def __GetEpisodesAndSeasons(self, mp4_files: list):
"""Get episodes grouped by season from mp4 files."""
episodes_dict = {}
for file in mp4_files:
@@ -115,13 +338,19 @@ class SerieScanner:
episodes_dict[season] = [episode]
return episodes_dict
def __GetMissingEpisodesAndSeason(self, key: str, mp4_files: []):
expected_dict = self.loader.get_season_episode_count(key) # key season , value count of episodes
def __GetMissingEpisodesAndSeason(self, key: str, mp4_files: list):
"""Get missing episodes for a serie."""
# key season , value count of episodes
expected_dict = self.loader.get_season_episode_count(key)
filedict = self.__GetEpisodesAndSeasons(mp4_files)
episodes_dict = {}
for season, expected_count in expected_dict.items():
existing_episodes = filedict.get(season, [])
missing_episodes = [ep for ep in range(1, expected_count + 1) if ep not in existing_episodes and self.loader.IsLanguage(season, ep, key)]
missing_episodes = [
ep for ep in range(1, expected_count + 1)
if ep not in existing_episodes
and self.loader.IsLanguage(season, ep, key)
]
if missing_episodes:
episodes_dict[season] = missing_episodes

View File

@@ -8,11 +8,20 @@ progress reporting, error handling, and operation cancellation.
import asyncio
import logging
import uuid
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Dict, List, Optional
from src.core.entities.SerieList import SerieList
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.core.providers.provider_factory import Loaders
from src.core.SerieScanner import SerieScanner
@@ -66,15 +75,17 @@ class SeriesApp:
self,
directory_to_search: str,
progress_callback: Optional[Callable[[ProgressInfo], None]] = None,
error_callback: Optional[Callable[[Exception], None]] = None
error_callback: Optional[Callable[[Exception], None]] = None,
callback_manager: Optional[CallbackManager] = None
):
"""
Initialize SeriesApp.
Args:
directory_to_search: Base directory for anime series
progress_callback: Optional callback for progress updates
error_callback: Optional callback for error notifications
progress_callback: Optional legacy callback for progress updates
error_callback: Optional legacy callback for error notifications
callback_manager: Optional callback manager for new callback system
"""
SeriesApp._initialization_count += 1
@@ -86,9 +97,13 @@ class SeriesApp:
self.progress_callback = progress_callback
self.error_callback = error_callback
# Initialize new callback system
self._callback_manager = callback_manager or CallbackManager()
# Cancellation support
self._cancel_flag = False
self._current_operation: Optional[str] = None
self._current_operation_id: Optional[str] = None
self._operation_status = OperationStatus.IDLE
# Initialize components
@@ -96,7 +111,9 @@ class SeriesApp:
self.Loaders = Loaders()
self.loader = self.Loaders.GetLoader(key="aniworld.to")
self.SerieScanner = SerieScanner(
directory_to_search, self.loader
directory_to_search,
self.loader,
self._callback_manager
)
self.List = SerieList(self.directory_to_search)
self.__InitList__()
@@ -110,6 +127,11 @@ class SeriesApp:
self._handle_error(e)
raise
@property
def callback_manager(self) -> CallbackManager:
"""Get the callback manager instance."""
return self._callback_manager
def __InitList__(self):
"""Initialize the series list with missing episodes."""
try:
@@ -163,13 +185,14 @@ class SeriesApp:
season: Season number
episode: Episode number
key: Serie key
callback: Optional progress callback
callback: Optional legacy progress callback
language: Language preference
Returns:
OperationResult with download status
"""
self._current_operation = f"download_S{season:02d}E{episode:02d}"
self._current_operation_id = str(uuid.uuid4())
self._operation_status = OperationStatus.RUNNING
self._cancel_flag = False
@@ -179,19 +202,81 @@ class SeriesApp:
serieFolder, season, episode
)
# Notify download starting
start_msg = (
f"Starting download: {serieFolder} "
f"S{season:02d}E{episode:02d}"
)
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=100,
percentage=0.0,
message=start_msg,
metadata={
"series": serieFolder,
"season": season,
"episode": episode,
"key": key,
"language": language
}
)
)
# Check for cancellation before starting
if self._is_cancelled():
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=False,
message="Download cancelled before starting"
)
)
return OperationResult(
success=False,
message="Download cancelled before starting"
)
# Wrap callback to check for cancellation
# Wrap callback to check for cancellation and report progress
def wrapped_callback(progress: float):
if self._is_cancelled():
raise InterruptedError("Download cancelled by user")
# Notify progress via new callback system
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=int(progress),
total=100,
percentage=progress,
message=f"Downloading: {progress:.1f}%",
metadata={
"series": serieFolder,
"season": season,
"episode": episode
}
)
)
# Call legacy callback if provided
if callback:
callback(progress)
# Call legacy progress_callback if provided
if self.progress_callback:
self.progress_callback(ProgressInfo(
current=int(progress),
total=100,
message=f"Downloading S{season:02d}E{episode:02d}",
percentage=progress,
status=OperationStatus.RUNNING
))
# Perform download
self.loader.Download(
@@ -210,7 +295,22 @@ class SeriesApp:
serieFolder, season, episode
)
# Notify completion
msg = f"Successfully downloaded S{season:02d}E{episode:02d}"
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=True,
message=msg,
statistics={
"series": serieFolder,
"season": season,
"episode": episode
}
)
)
return OperationResult(
success=True,
message=msg
@@ -219,6 +319,17 @@ class SeriesApp:
except InterruptedError as e:
self._operation_status = OperationStatus.CANCELLED
logger.warning("Download cancelled: %s", e)
# Notify cancellation
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=False,
message="Download cancelled"
)
)
return OperationResult(
success=False,
message="Download cancelled",
@@ -227,14 +338,43 @@ class SeriesApp:
except (IOError, OSError, RuntimeError) as e:
self._operation_status = OperationStatus.FAILED
logger.error("Download failed: %s", e)
# Notify error
error_msg = f"Download failed: {str(e)}"
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=False,
metadata={
"series": serieFolder,
"season": season,
"episode": episode
}
)
)
# Notify completion with failure
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.DOWNLOAD,
operation_id=self._current_operation_id,
success=False,
message=error_msg
)
)
self._handle_error(e)
return OperationResult(
success=False,
message=f"Download failed: {str(e)}",
message=error_msg,
error=e
)
finally:
self._current_operation = None
self._current_operation_id = None
def ReScan(
self,

View File

@@ -0,0 +1,347 @@
"""
Progress callback interfaces for core operations.
This module defines clean interfaces for progress reporting, error handling,
and completion notifications across all core operations (scanning,
downloading).
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, Optional
class OperationType(str, Enum):
"""Types of operations that can report progress."""
SCAN = "scan"
DOWNLOAD = "download"
SEARCH = "search"
INITIALIZATION = "initialization"
class ProgressPhase(str, Enum):
"""Phases of an operation's lifecycle."""
STARTING = "starting"
IN_PROGRESS = "in_progress"
COMPLETING = "completing"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass
class ProgressContext:
"""
Complete context information for a progress update.
Attributes:
operation_type: Type of operation being performed
operation_id: Unique identifier for this operation
phase: Current phase of the operation
current: Current progress value (e.g., files processed)
total: Total progress value (e.g., total files)
percentage: Completion percentage (0.0 to 100.0)
message: Human-readable progress message
details: Additional context-specific details
metadata: Extra metadata for specialized use cases
"""
operation_type: OperationType
operation_id: str
phase: ProgressPhase
current: int
total: int
percentage: float
message: str
details: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"operation_type": self.operation_type.value,
"operation_id": self.operation_id,
"phase": self.phase.value,
"current": self.current,
"total": self.total,
"percentage": round(self.percentage, 2),
"message": self.message,
"details": self.details,
"metadata": self.metadata,
}
@dataclass
class ErrorContext:
"""
Context information for error callbacks.
Attributes:
operation_type: Type of operation that failed
operation_id: Unique identifier for the operation
error: The exception that occurred
message: Human-readable error message
recoverable: Whether the error is recoverable
retry_count: Number of retry attempts made
metadata: Additional error context
"""
operation_type: OperationType
operation_id: str
error: Exception
message: str
recoverable: bool = False
retry_count: int = 0
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"operation_type": self.operation_type.value,
"operation_id": self.operation_id,
"error_type": type(self.error).__name__,
"error_message": str(self.error),
"message": self.message,
"recoverable": self.recoverable,
"retry_count": self.retry_count,
"metadata": self.metadata,
}
@dataclass
class CompletionContext:
"""
Context information for completion callbacks.
Attributes:
operation_type: Type of operation that completed
operation_id: Unique identifier for the operation
success: Whether the operation completed successfully
message: Human-readable completion message
result_data: Result data from the operation
statistics: Operation statistics (duration, items processed, etc.)
metadata: Additional completion context
"""
operation_type: OperationType
operation_id: str
success: bool
message: str
result_data: Optional[Any] = None
statistics: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"operation_type": self.operation_type.value,
"operation_id": self.operation_id,
"success": self.success,
"message": self.message,
"statistics": self.statistics,
"metadata": self.metadata,
}
class ProgressCallback(ABC):
"""
Abstract base class for progress callbacks.
Implement this interface to receive progress updates from core operations.
"""
@abstractmethod
def on_progress(self, context: ProgressContext) -> None:
"""
Called when progress is made in an operation.
Args:
context: Complete progress context information
"""
pass
class ErrorCallback(ABC):
"""
Abstract base class for error callbacks.
Implement this interface to receive error notifications from core
operations.
"""
@abstractmethod
def on_error(self, context: ErrorContext) -> None:
"""
Called when an error occurs during an operation.
Args:
context: Complete error context information
"""
pass
class CompletionCallback(ABC):
"""
Abstract base class for completion callbacks.
Implement this interface to receive completion notifications from
core operations.
"""
@abstractmethod
def on_completion(self, context: CompletionContext) -> None:
"""
Called when an operation completes (successfully or not).
Args:
context: Complete completion context information
"""
pass
class CallbackManager:
"""
Manages multiple callbacks for an operation.
This class allows registering multiple progress, error, and completion
callbacks and dispatching events to all registered callbacks.
"""
def __init__(self):
"""Initialize the callback manager."""
self._progress_callbacks: list[ProgressCallback] = []
self._error_callbacks: list[ErrorCallback] = []
self._completion_callbacks: list[CompletionCallback] = []
def register_progress_callback(self, callback: ProgressCallback) -> None:
"""
Register a progress callback.
Args:
callback: Progress callback to register
"""
if callback not in self._progress_callbacks:
self._progress_callbacks.append(callback)
def register_error_callback(self, callback: ErrorCallback) -> None:
"""
Register an error callback.
Args:
callback: Error callback to register
"""
if callback not in self._error_callbacks:
self._error_callbacks.append(callback)
def register_completion_callback(
self,
callback: CompletionCallback
) -> None:
"""
Register a completion callback.
Args:
callback: Completion callback to register
"""
if callback not in self._completion_callbacks:
self._completion_callbacks.append(callback)
def unregister_progress_callback(self, callback: ProgressCallback) -> None:
"""
Unregister a progress callback.
Args:
callback: Progress callback to unregister
"""
if callback in self._progress_callbacks:
self._progress_callbacks.remove(callback)
def unregister_error_callback(self, callback: ErrorCallback) -> None:
"""
Unregister an error callback.
Args:
callback: Error callback to unregister
"""
if callback in self._error_callbacks:
self._error_callbacks.remove(callback)
def unregister_completion_callback(
self,
callback: CompletionCallback
) -> None:
"""
Unregister a completion callback.
Args:
callback: Completion callback to unregister
"""
if callback in self._completion_callbacks:
self._completion_callbacks.remove(callback)
def notify_progress(self, context: ProgressContext) -> None:
"""
Notify all registered progress callbacks.
Args:
context: Progress context to send
"""
for callback in self._progress_callbacks:
try:
callback.on_progress(context)
except Exception as e:
# Log but don't let callback errors break the operation
logging.error(
"Error in progress callback %s: %s",
callback,
e,
exc_info=True
)
def notify_error(self, context: ErrorContext) -> None:
"""
Notify all registered error callbacks.
Args:
context: Error context to send
"""
for callback in self._error_callbacks:
try:
callback.on_error(context)
except Exception as e:
# Log but don't let callback errors break the operation
logging.error(
"Error in error callback %s: %s",
callback,
e,
exc_info=True
)
def notify_completion(self, context: CompletionContext) -> None:
"""
Notify all registered completion callbacks.
Args:
context: Completion context to send
"""
for callback in self._completion_callbacks:
try:
callback.on_completion(context)
except Exception as e:
# Log but don't let callback errors break the operation
logging.error(
"Error in completion callback %s: %s",
callback,
e,
exc_info=True
)
def clear_all_callbacks(self) -> None:
"""Clear all registered callbacks."""
self._progress_callbacks.clear()
self._error_callbacks.clear()
self._completion_callbacks.clear()