Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/commands/apply.py

248 lines
9.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2025 Allaun
"""Apply tool for NoDupeLabs.
This tool provides the apply functionality as a tool that can be
loaded by the core system. It demonstrates how to convert existing
modules to tools.
Key Features:
- File management actions
- Duplicate handling
- Progress tracking
- Tool integration
Dependencies:
- Core modules
"""
from typing import Any, Dict
import argparse
from pathlib import Path
from nodupe.core.tool_system.base import Tool
from nodupe.tools.os_filesystem.filesystem import Filesystem
from nodupe.tools.databases.files import FileRepository
from nodupe.tools.databases.connection import DatabaseConnection
class ApplyTool(Tool):
"""Apply tool implementation."""
name = "apply"
version = "1.0.0"
dependencies = []
def __init__(self):
"""Initialize apply tool."""
self.description = "Apply actions to duplicate files"
def initialize(self, container: Any) -> None:
"""Initialize the tool."""
def shutdown(self) -> None:
"""Shutdown the tool."""
def get_capabilities(self) -> Dict[str, Any]:
"""Get tool capabilities."""
return {'commands': ['apply']}
@property
def api_methods(self) -> Dict[str, Any]:
"""Get API methods exposed by this tool."""
return {}
def describe_usage(self) -> str:
"""Return human-readable usage instructions."""
return "Apply file management actions to duplicate files: delete, move, copy, or list"
def run_standalone(self, args: Any) -> int:
"""Execute the tool in standalone mode."""
return self.execute_apply(args)
def _on_apply_start(self, **kwargs: Any) -> None:
"""Handle apply start event."""
print(f"[TOOL] Apply started: {kwargs.get('action', 'unknown')}")
def _on_apply_complete(self, **kwargs: Any) -> None:
"""Handle apply complete event."""
print(f"[TOOL] Apply completed: {kwargs.get('files_processed', 0)} files processed")
def register_commands(self, subparsers: Any) -> None:
"""Register apply command with argument parser."""
apply_parser = subparsers.add_parser('apply', help='Apply actions to duplicates')
apply_parser.add_argument(
'action',
choices=['delete', 'move', 'copy', 'list'],
help='Action to perform'
)
apply_parser.add_argument(
'--destination', '-d',
help='Destination directory (required for move/copy)'
)
apply_parser.add_argument('--dry-run', action='store_true', help='Dry run (no changes)')
apply_parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
apply_parser.set_defaults(func=self.execute_apply)
def execute_apply(self, args: argparse.Namespace) -> int:
"""Execute apply command.
Args:
args: Command arguments including injected 'container'
"""
try:
# Validation
# Check if input is provided (for compatibility with tests)
if hasattr(args, 'input') and args.input is None:
print("[ERROR] Input file is required")
return 1
# Validate input file exists
if hasattr(args, 'input') and args.input:
import os
if not os.path.exists(args.input):
print(f"[ERROR] Input file does not exist: {args.input}")
return 1
# Validate action is one of the allowed choices
valid_actions = ['delete', 'move', 'copy', 'list']
if args.action not in valid_actions:
print(f"[ERROR] Invalid action: {args.action}. Must be one of: {', '.join(valid_actions)}")
return 1
# Validate destination for move/copy actions
if args.action in ['move', 'copy'] and not args.destination:
print("[ERROR] --destination is required for move/copy actions")
return 1
# Validate target directory for move/copy
if args.action in ['move', 'copy']:
if hasattr(args, 'destination') and args.destination:
import os
if not os.path.exists(args.destination):
print(f"[ERROR] Target directory does not exist: {args.destination}")
return 1
print(f"[TOOL] Executing apply command: {args.action}")
# 1. Get services
container = getattr(args, 'container', None)
if not container:
print("[ERROR] Dependency container not available")
return 1
db_connection = container.get_service('database')
if not db_connection:
print("[ERROR] Database service not available")
# Fallback to default
db_connection = DatabaseConnection.get_instance()
file_repo = FileRepository(db_connection)
# 2. Get duplicates
duplicates = file_repo.get_duplicate_files()
if not duplicates:
print("[TOOL] No items marked as duplicates in database.")
print(" (Did you run 'scan' and 'sim' commands first?)")
return 0
print(f"[TOOL] Found {len(duplicates)} duplicate files identified in database")
files_processed = 0
if args.action == 'list':
print("\nIdentified Duplicates:")
for dup in duplicates:
original_id = dup['duplicate_of']
original = file_repo.get_file(original_id)
orig_path = original['path'] if original else "?"
print(f" {dup['path']} (Duplicate of: {orig_path})")
return 0
# 3. Process Actions
for dup in duplicates:
path = Path(dup['path'])
try:
if not path.exists():
print(f"[WARN] File not found: {path} (skipping)")
continue
if args.action == 'delete':
if args.dry_run:
print(f"[DRY-RUN] Would delete: {path}")
else:
Filesystem.remove_file(path)
file_repo.delete_file(dup['id'])
print(f"[DELETED] {path}")
elif args.action in ['move', 'copy']:
if not args.destination: # Should verify logic above
continue
dest_dir = Path(args.destination)
dest_path = dest_dir / path.name
# Handle collision
if dest_path.exists():
# Simple rename logic
stem = dest_path.stem
suffix = dest_path.suffix
dest_path = dest_dir / f"{stem}_{dup['id']}{suffix}"
if args.action == 'move':
if args.dry_run:
print(f"[DRY-RUN] Would move: {path} -> {dest_path}")
else:
Filesystem.ensure_directory(dest_dir)
Filesystem.move_file(path, dest_path)
# Update DB to point to new location or just delete entry?
# Usually duplicates are processed to get rid of them or archive them.
# If moved, we might update the path in DB or remove if 'archived' implies removal from active working set.
# Let's remove from DB as "processed duplicate".
file_repo.delete_file(dup['id'])
print(f"[MOVED] {path} -> {dest_path}")
elif args.action == 'copy':
if args.dry_run:
print(f"[DRY-RUN] Would copy: {path} -> {dest_path}")
else:
Filesystem.ensure_directory(dest_dir)
Filesystem.copy_file(path, dest_path)
print(f"[COPIED] {path} -> {dest_path}")
files_processed += 1
except Exception as e:
print(f"[ERROR] Failed to process {path}: {e}")
if args.dry_run:
print(f"\n[TOOL] Dry run complete. Would process {files_processed} files.")
else:
print(f"\n[TOOL] Apply complete. Processed {files_processed} files.")
self._on_apply_complete(files_processed=files_processed)
return 0
except Exception as e:
print(f"[TOOL ERROR] Apply failed: {e}")
if args.verbose:
import traceback
traceback.print_exc()
return 1
# Create tool instance when module is loaded
apply_tool = ApplyTool()
# Register tool with core system
def register_tool():
"""Register tool with core system."""
return apply_tool
# Export tool interface
__all__ = ['apply_tool', 'register_tool']