feat: implement session management functionality with save/load capabilities and error handling

This commit is contained in:
Benexl
2025-07-14 21:23:31 +03:00
parent 064401f8e8
commit 222c50b4b2
5 changed files with 872 additions and 27 deletions

View File

@@ -59,7 +59,8 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
ctx, "REPEATING"
),
# --- Control Flow and Utility Options ---
f"{'📝 ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None),
f"{'<EFBFBD> ' if icons else ''}Session Management": lambda: ("SESSION_MANAGEMENT", None),
f"{'<EFBFBD>📝 ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None),
f"{'' if icons else ''}Exit": lambda: ("EXIT", None),
}
@@ -81,6 +82,8 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
return ControlFlow.EXIT
if next_menu_name == "RELOAD_CONFIG":
return ControlFlow.RELOAD_CONFIG
if next_menu_name == "SESSION_MANAGEMENT":
return State(menu_name="SESSION_MANAGEMENT")
if next_menu_name == "CONTINUE":
return ControlFlow.CONTINUE

View File

@@ -0,0 +1,241 @@
"""
Session management menu for the interactive CLI.
Provides options to save, load, and manage session state.
"""
from datetime import datetime
from pathlib import Path
from typing import Callable, Dict
from rich.console import Console
from rich.table import Table
from ....core.constants import APP_DIR
from ...utils.feedback import create_feedback_manager
from ..session import Context, session
from ..state import ControlFlow, State
MenuAction = Callable[[], str]
@session.menu
def session_management(ctx: Context, state: State) -> State | ControlFlow:
"""
Session management menu for saving, loading, and managing session state.
"""
icons = ctx.config.general.icons
feedback = create_feedback_manager(icons)
console = Console()
console.clear()
# Show current session stats
_display_session_info(console, icons)
options: Dict[str, MenuAction] = {
f"{'💾 ' if icons else ''}Save Current Session": lambda: _save_session(ctx, feedback),
f"{'📂 ' if icons else ''}Load Session": lambda: _load_session(ctx, feedback),
f"{'📋 ' if icons else ''}List Saved Sessions": lambda: _list_sessions(ctx, feedback),
f"{'🗑️ ' if icons else ''}Cleanup Old Sessions": lambda: _cleanup_sessions(ctx, feedback),
f"{'💾 ' if icons else ''}Create Manual Backup": lambda: _create_backup(ctx, feedback),
f"{'⚙️ ' if icons else ''}Session Settings": lambda: _session_settings(ctx, feedback),
f"{'🔙 ' if icons else ''}Back to Main Menu": lambda: "BACK",
}
choice_str = ctx.selector.choose(
prompt="Select Session Action",
choices=list(options.keys()),
header="Session Management",
)
if not choice_str:
return ControlFlow.BACK
result = options[choice_str]()
if result == "BACK":
return ControlFlow.BACK
else:
return ControlFlow.CONTINUE
def _display_session_info(console: Console, icons: bool):
"""Display current session information."""
session_stats = session.get_session_stats()
table = Table(title=f"{'📊 ' if icons else ''}Current Session Info")
table.add_column("Property", style="cyan")
table.add_column("Value", style="green")
table.add_row("Current States", str(session_stats["current_states"]))
table.add_row("Current Menu", session_stats["current_menu"] or "None")
table.add_row("Auto-Save", "Enabled" if session_stats["auto_save_enabled"] else "Disabled")
table.add_row("Has Auto-Save", "Yes" if session_stats["has_auto_save"] else "No")
table.add_row("Has Crash Backup", "Yes" if session_stats["has_crash_backup"] else "No")
console.print(table)
console.print()
def _save_session(ctx: Context, feedback) -> str:
"""Save the current session."""
session_name = ctx.selector.ask("Enter session name (optional):")
description = ctx.selector.ask("Enter session description (optional):")
if not session_name:
session_name = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
sessions_dir = APP_DIR / "sessions"
file_path = sessions_dir / f"{session_name}.json"
if file_path.exists():
if not feedback.confirm(f"Session '{session_name}' already exists. Overwrite?"):
feedback.info("Save cancelled")
return "CONTINUE"
success = session.save(file_path, session_name, description or "")
if success:
feedback.success(f"Session saved as '{session_name}'")
return "CONTINUE"
def _load_session(ctx: Context, feedback) -> str:
"""Load a saved session."""
sessions = session.list_saved_sessions()
if not sessions:
feedback.warning("No saved sessions found")
return "CONTINUE"
# Create choices with session info
choices = []
session_map = {}
for sess in sessions:
choice_text = f"{sess['name']} - {sess['description'][:50]}{'...' if len(sess['description']) > 50 else ''}"
choices.append(choice_text)
session_map[choice_text] = sess
choices.append("Cancel")
choice = ctx.selector.choose(
"Select session to load:",
choices=choices,
header="Available Sessions"
)
if not choice or choice == "Cancel":
return "CONTINUE"
selected_session = session_map[choice]
file_path = Path(selected_session["path"])
if feedback.confirm(f"Load session '{selected_session['name']}'? This will replace your current session."):
success = session.resume(file_path, feedback)
if success:
feedback.info("Session loaded successfully. Returning to main menu.")
# Return to main menu after loading
return "MAIN"
return "CONTINUE"
def _list_sessions(ctx: Context, feedback) -> str:
"""List all saved sessions."""
sessions = session.list_saved_sessions()
if not sessions:
feedback.info("No saved sessions found")
return "CONTINUE"
console = Console()
table = Table(title="Saved Sessions")
table.add_column("Name", style="cyan")
table.add_column("Description", style="yellow")
table.add_column("States", style="green")
table.add_column("Created", style="blue")
for sess in sessions:
# Format the created date
created = sess["created"]
if "T" in created:
created = created.split("T")[0] # Just show the date part
table.add_row(
sess["name"],
sess["description"][:40] + "..." if len(sess["description"]) > 40 else sess["description"],
str(sess["state_count"]),
created
)
console.print(table)
feedback.pause_for_user()
return "CONTINUE"
def _cleanup_sessions(ctx: Context, feedback) -> str:
"""Clean up old sessions."""
sessions = session.list_saved_sessions()
if len(sessions) <= 5:
feedback.info("No cleanup needed. You have 5 or fewer sessions.")
return "CONTINUE"
max_sessions_str = ctx.selector.ask("How many sessions to keep? (default: 10)")
try:
max_sessions = int(max_sessions_str) if max_sessions_str else 10
except ValueError:
feedback.error("Invalid number entered")
return "CONTINUE"
if feedback.confirm(f"Delete sessions older than the {max_sessions} most recent?"):
deleted_count = session.cleanup_old_sessions(max_sessions)
feedback.success(f"Deleted {deleted_count} old sessions")
return "CONTINUE"
def _create_backup(ctx: Context, feedback) -> str:
"""Create a manual backup."""
backup_name = ctx.selector.ask("Enter backup name (optional):")
success = session.create_manual_backup(backup_name or "")
if success:
feedback.success("Manual backup created successfully")
return "CONTINUE"
def _session_settings(ctx: Context, feedback) -> str:
"""Configure session settings."""
current_auto_save = session._auto_save_enabled
choices = [
f"Auto-Save: {'Enabled' if current_auto_save else 'Disabled'}",
"Clear Auto-Save File",
"Clear Crash Backup",
"Back"
]
choice = ctx.selector.choose(
"Session Settings:",
choices=choices
)
if choice and choice.startswith("Auto-Save"):
new_setting = not current_auto_save
session.enable_auto_save(new_setting)
feedback.success(f"Auto-save {'enabled' if new_setting else 'disabled'}")
elif choice == "Clear Auto-Save File":
if feedback.confirm("Clear the auto-save file?"):
session._session_manager.clear_auto_save()
feedback.success("Auto-save file cleared")
elif choice == "Clear Crash Backup":
if feedback.confirm("Clear the crash backup file?"):
session._session_manager.clear_crash_backup()
feedback.success("Crash backup cleared")
return "CONTINUE"

View File

@@ -2,8 +2,9 @@ import importlib.util
import logging
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Callable, List
from typing import Callable, List
import click
@@ -14,6 +15,7 @@ from ...libs.players.base import BasePlayer
from ...libs.providers.anime.base import BaseAnimeProvider
from ...libs.selectors.base import BaseSelector
from ..config import ConfigLoader
from ..utils.session_manager import SessionManager
from .state import ControlFlow, State
logger = logging.getLogger(__name__)
@@ -59,6 +61,8 @@ class Session:
self._context: Context | None = None
self._history: List[State] = []
self._menus: dict[str, Menu] = {}
self._session_manager = SessionManager()
self._auto_save_enabled = True
def _load_context(self, config: AppConfig):
"""Initializes all shared services based on the provided configuration."""
@@ -152,14 +156,60 @@ class Session:
config: The initial application configuration.
resume_path: Optional path to a saved session file to resume from.
"""
from ..utils.feedback import create_feedback_manager
feedback = create_feedback_manager(True) # Always use icons for session messages
self._load_context(config)
# Handle session recovery
if resume_path:
self.resume(resume_path)
elif not self._history:
# Start with the main menu if history is empty
self.resume(resume_path, feedback)
elif self._session_manager.has_crash_backup():
# Offer to resume from crash backup
if feedback.confirm(
"Found a crash backup from a previous session. Would you like to resume?",
default=True
):
crash_history = self._session_manager.load_crash_backup(feedback)
if crash_history:
self._history = crash_history
feedback.info("Session restored from crash backup")
# Clear the crash backup after successful recovery
self._session_manager.clear_crash_backup()
elif self._session_manager.has_auto_save():
# Offer to resume from auto-save
if feedback.confirm(
"Found an auto-saved session. Would you like to resume?",
default=False
):
auto_history = self._session_manager.load_auto_save(feedback)
if auto_history:
self._history = auto_history
feedback.info("Session restored from auto-save")
# Start with main menu if no history
if not self._history:
self._history.append(State(menu_name="MAIN"))
# Create crash backup before starting
if self._auto_save_enabled:
self._session_manager.create_crash_backup(self._history)
try:
self._run_main_loop()
except KeyboardInterrupt:
feedback.warning("Session interrupted by user")
self._handle_session_exit(feedback, interrupted=True)
except Exception as e:
feedback.error("Session crashed unexpectedly", str(e))
self._handle_session_exit(feedback, crashed=True)
raise
else:
self._handle_session_exit(feedback, normal_exit=True)
def _run_main_loop(self):
"""Run the main session loop."""
while self._history:
current_state = self._history[-1]
menu_to_run = self._menus.get(current_state.menu_name)
@@ -170,6 +220,10 @@ class Session:
)
break
# Auto-save periodically (every 5 state changes)
if self._auto_save_enabled and len(self._history) % 5 == 0:
self._session_manager.auto_save_session(self._history)
# Execute the menu function, which returns the next step.
next_step = menu_to_run.execute(self._context, current_state)
@@ -187,37 +241,109 @@ class Session:
# if the state is main menu we should reset the history
if next_step.menu_name == "MAIN":
self._history = [next_step]
# A new state was returned, push it to history for the next loop.
self._history.append(next_step)
else:
# A new state was returned, push it to history for the next loop.
self._history.append(next_step)
else:
logger.error(
f"Menu '{current_state.menu_name}' returned invalid type: {type(next_step)}"
)
break
def _handle_session_exit(self, feedback, normal_exit=False, interrupted=False, crashed=False):
"""Handle session cleanup on exit."""
if self._auto_save_enabled and self._history:
if normal_exit:
# Clear auto-save on normal exit
self._session_manager.clear_auto_save()
self._session_manager.clear_crash_backup()
feedback.info("Session completed normally")
elif interrupted:
# Save session on interruption
self._session_manager.auto_save_session(self._history)
feedback.info("Session auto-saved due to interruption")
elif crashed:
# Keep crash backup on crash
feedback.error("Session backup maintained for recovery")
click.echo("Exiting interactive session.")
def save(self, file_path: Path):
"""Serializes the session history to a JSON file."""
history_dicts = [state.model_dump(mode="json") for state in self._history]
try:
file_path.write_text(str(history_dicts))
logger.info(f"Session saved to {file_path}")
except IOError as e:
logger.error(f"Failed to save session: {e}")
def save(self, file_path: Path, session_name: str = None, description: str = None):
"""
Save session history to a file with comprehensive metadata and error handling.
Args:
file_path: Path to save the session
session_name: Optional name for the session
description: Optional description for the session
"""
from ..utils.feedback import create_feedback_manager
feedback = create_feedback_manager(True)
return self._session_manager.save_session(
self._history,
file_path,
session_name=session_name,
description=description,
feedback=feedback
)
def resume(self, file_path: Path):
"""Loads a session history from a JSON file."""
if not file_path.exists():
logger.warning(f"Resume file not found: {file_path}")
return
try:
history_dicts = file_path.read_text()
self._history = [State.model_validate(d) for d in history_dicts]
logger.info(f"Session resumed from {file_path}")
except Exception as e:
logger.error(f"Failed to resume session: {e}")
self._history = [] # Reset history on failure
def resume(self, file_path: Path, feedback=None):
"""
Load session history from a file with comprehensive error handling.
Args:
file_path: Path to the session file
feedback: Optional feedback manager for user notifications
"""
if not feedback:
from ..utils.feedback import create_feedback_manager
feedback = create_feedback_manager(True)
history = self._session_manager.load_session(file_path, feedback)
if history:
self._history = history
return True
return False
def list_saved_sessions(self):
"""List all saved sessions with their metadata."""
return self._session_manager.list_saved_sessions()
def cleanup_old_sessions(self, max_sessions: int = 10):
"""Clean up old session files, keeping only the most recent ones."""
return self._session_manager.cleanup_old_sessions(max_sessions)
def enable_auto_save(self, enabled: bool = True):
"""Enable or disable auto-save functionality."""
self._auto_save_enabled = enabled
def get_session_stats(self) -> dict:
"""Get statistics about the current session."""
return {
"current_states": len(self._history),
"current_menu": self._history[-1].menu_name if self._history else None,
"auto_save_enabled": self._auto_save_enabled,
"has_auto_save": self._session_manager.has_auto_save(),
"has_crash_backup": self._session_manager.has_crash_backup()
}
def create_manual_backup(self, backup_name: str = None):
"""Create a manual backup of the current session."""
from ..utils.feedback import create_feedback_manager
from ...core.constants import APP_DIR
feedback = create_feedback_manager(True)
backup_name = backup_name or f"manual_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
backup_path = APP_DIR / "sessions" / f"{backup_name}.json"
return self._session_manager.save_session(
self._history,
backup_path,
session_name=backup_name,
description="Manual backup created by user",
feedback=feedback
)
@property
def menu(self) -> Callable[[MenuFunction], MenuFunction]: