This commit is contained in:
mike
2025-12-13 11:56:06 +01:00
commit 2b2c575385
57 changed files with 6505 additions and 0 deletions

50
app/shared/__init__.py Normal file
View File

@@ -0,0 +1,50 @@
"""Shared package exports"""
from .models import (
FileRecord,
OperationRecord,
DiskInfo,
MigrationPlan,
ProcessingStats
)
from .config import (
Config,
DatabaseConfig,
ProcessingConfig,
LoggingConfig,
load_config
)
from .logger import (
ProgressLogger,
create_logger,
format_size,
format_rate,
format_time
)
from ._protocols import IDatabase, ILogger
__all__ = [
# Models
'FileRecord',
'OperationRecord',
'DiskInfo',
'MigrationPlan',
'ProcessingStats',
# Config
'Config',
'DatabaseConfig',
'ProcessingConfig',
'LoggingConfig',
'load_config',
# Logger
'ProgressLogger',
'create_logger',
'format_size',
'format_rate',
'format_time',
# Protocols
'IDatabase',
'ILogger',
]

67
app/shared/_protocols.py Normal file
View File

@@ -0,0 +1,67 @@
"""Protocol definitions for the shared package"""
from typing import Protocol, Any
from pathlib import Path
from dataclasses import dataclass
from datetime import datetime
@dataclass
class FileRecord:
"""Core file record with all metadata"""
path: Path
size: int
modified_time: float
created_time: float
disk_label: str
checksum: str | None = None
status: str = 'indexed' # indexed, planned, moved, verified
category: str | None = None
duplicate_of: str | None = None
@dataclass
class OperationRecord:
"""Record of a migration operation"""
source_path: Path
target_path: Path
operation_type: str # move, copy, hardlink, symlink
status: str = 'pending' # pending, in_progress, completed, failed
error: str | None = None
executed_at: datetime | None = None
verified: bool = False
class IDatabase(Protocol):
"""Protocol for database operations"""
def store_file(self, file_record: FileRecord) -> None:
"""Store a file record"""
...
def get_files_by_disk(self, disk: str) -> list[FileRecord]:
"""Get all files on a specific disk"""
...
def store_operation(self, operation: OperationRecord) -> None:
"""Store an operation record"""
...
def get_pending_operations(self) -> list[OperationRecord]:
"""Get all pending operations"""
...
class ILogger(Protocol):
"""Protocol for logging operations"""
def info(self, message: str) -> None:
...
def warning(self, message: str) -> None:
...
def error(self, message: str) -> None:
...
def debug(self, message: str) -> None:
...

110
app/shared/config.py Normal file
View File

@@ -0,0 +1,110 @@
"""Configuration management for disk reorganizer"""
import json
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Optional
@dataclass
class DatabaseConfig:
"""Database connection configuration"""
host: str = '192.168.1.159'
port: int = 5432
database: str = 'disk_reorganizer_db'
user: str = 'disk_reorg_user'
password: str = 'heel-goed-wachtwoord'
def to_dict(self) -> dict:
"""Convert to dictionary"""
return asdict(self)
@dataclass
class ProcessingConfig:
"""Processing behavior configuration"""
batch_size: int = 1000
commit_interval: int = 100
parallel_workers: int = 4
chunk_size: int = 8192
hash_algorithm: str = 'sha256'
verify_operations: bool = True
preserve_timestamps: bool = True
def to_dict(self) -> dict:
"""Convert to dictionary"""
return asdict(self)
@dataclass
class LoggingConfig:
"""Logging configuration"""
level: str = 'INFO'
log_file: str = 'disk_reorganizer.log'
console_output: bool = True
file_output: bool = True
def to_dict(self) -> dict:
"""Convert to dictionary"""
return asdict(self)
@dataclass
class Config:
"""Main configuration container"""
database: DatabaseConfig = None
processing: ProcessingConfig = None
logging: LoggingConfig = None
def __post_init__(self):
"""Initialize nested configs with defaults if not provided"""
if self.database is None:
self.database = DatabaseConfig()
if self.processing is None:
self.processing = ProcessingConfig()
if self.logging is None:
self.logging = LoggingConfig()
@classmethod
def from_file(cls, config_path: Path) -> 'Config':
"""Load configuration from JSON file"""
if not config_path.exists():
return cls()
with open(config_path, 'r') as f:
data = json.load(f)
return cls(
database=DatabaseConfig(**data.get('database', {})),
processing=ProcessingConfig(**data.get('processing', {})),
logging=LoggingConfig(**data.get('logging', {}))
)
def to_file(self, config_path: Path) -> None:
"""Save configuration to JSON file"""
data = {
'database': self.database.to_dict(),
'processing': self.processing.to_dict(),
'logging': self.logging.to_dict()
}
with open(config_path, 'w') as f:
json.dump(data, f, indent=2)
def to_dict(self) -> dict:
"""Convert to dictionary"""
return {
'database': self.database.to_dict(),
'processing': self.processing.to_dict(),
'logging': self.logging.to_dict()
}
def load_config(config_path: Optional[Path] = None) -> Config:
"""Load configuration from file or return default"""
if config_path is None:
config_path = Path('config.json')
if config_path.exists():
return Config.from_file(config_path)
return Config()

217
app/shared/logger.py Normal file
View File

@@ -0,0 +1,217 @@
"""Dynamic progress logger with formatting utilities"""
import sys
import logging
from typing import Optional
from datetime import datetime
from pathlib import Path
def format_size(bytes_size: int) -> str:
"""Format bytes to human-readable size string
Args:
bytes_size: Size in bytes
Returns:
Human-readable size string (e.g., "1.5 GB", "234.5 MB")
"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']:
if bytes_size < 1024.0:
return f"{bytes_size:.1f} {unit}"
bytes_size /= 1024.0
return f"{bytes_size:.1f} EB"
def format_rate(bytes_per_second: float) -> str:
"""Format transfer rate to human-readable string
Args:
bytes_per_second: Transfer rate in bytes per second
Returns:
Human-readable rate string (e.g., "125.3 MB/s")
"""
return f"{format_size(int(bytes_per_second))}/s"
def format_time(seconds: float) -> str:
"""Format seconds to human-readable time string
Args:
seconds: Time in seconds
Returns:
Human-readable time string (e.g., "2h 34m 12s", "45m 23s", "12s")
"""
if seconds < 60:
return f"{int(seconds)}s"
elif seconds < 3600:
minutes = int(seconds // 60)
secs = int(seconds % 60)
return f"{minutes}m {secs}s"
else:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
return f"{hours}h {minutes}m {secs}s"
class ProgressLogger:
"""Dynamic progress logger with real-time statistics"""
def __init__(
self,
name: str = "defrag",
level: int = logging.INFO,
log_file: Optional[Path] = None,
console_output: bool = True
):
"""Initialize progress logger
Args:
name: Logger name
level: Logging level
log_file: Optional log file path
console_output: Whether to output to console
"""
self.logger = logging.getLogger(name)
self.logger.setLevel(level)
self.logger.handlers.clear()
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Add console handler
if console_output:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler)
# Add file handler
if log_file:
log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
self._last_progress_line = ""
def info(self, message: str) -> None:
"""Log info message"""
self.logger.info(message)
def warning(self, message: str) -> None:
"""Log warning message"""
self.logger.warning(message)
def error(self, message: str) -> None:
"""Log error message"""
self.logger.error(message)
def debug(self, message: str) -> None:
"""Log debug message"""
self.logger.debug(message)
def critical(self, message: str) -> None:
"""Log critical message"""
self.logger.critical(message)
def progress(
self,
current: int,
total: int,
prefix: str = "",
suffix: str = "",
bytes_processed: Optional[int] = None,
elapsed_seconds: Optional[float] = None
) -> None:
"""Log progress with dynamic statistics
Args:
current: Current progress count
total: Total count
prefix: Prefix message
suffix: Suffix message
bytes_processed: Optional bytes processed for rate calculation
elapsed_seconds: Optional elapsed time for rate calculation
"""
if total == 0:
percent = 0.0
else:
percent = (current / total) * 100
progress_msg = f"{prefix} [{current}/{total}] {percent:.1f}%"
if bytes_processed is not None and elapsed_seconds is not None and elapsed_seconds > 0:
rate = bytes_per_second = bytes_processed / elapsed_seconds
progress_msg += f" | {format_size(bytes_processed)} @ {format_rate(rate)}"
# Estimate time remaining
if current > 0:
estimated_total_seconds = (elapsed_seconds / current) * total
remaining_seconds = estimated_total_seconds - elapsed_seconds
progress_msg += f" | ETA: {format_time(remaining_seconds)}"
if suffix:
progress_msg += f" | {suffix}"
self.info(progress_msg)
def section(self, title: str) -> None:
"""Log section header
Args:
title: Section title
"""
separator = "=" * 60
self.info(separator)
self.info(f" {title}")
self.info(separator)
def subsection(self, title: str) -> None:
"""Log subsection header
Args:
title: Subsection title
"""
self.info(f"\n--- {title} ---")
def create_logger(
name: str = "defrag",
level: str = "INFO",
log_file: Optional[Path] = None,
console_output: bool = True
) -> ProgressLogger:
"""Create and configure a progress logger
Args:
name: Logger name
level: Logging level as string
log_file: Optional log file path
console_output: Whether to output to console
Returns:
Configured ProgressLogger instance
"""
level_map = {
'DEBUG': logging.DEBUG,
'INFO': logging.INFO,
'WARNING': logging.WARNING,
'ERROR': logging.ERROR,
'CRITICAL': logging.CRITICAL
}
log_level = level_map.get(level.upper(), logging.INFO)
return ProgressLogger(
name=name,
level=log_level,
log_file=log_file,
console_output=console_output
)

127
app/shared/models.py Normal file
View File

@@ -0,0 +1,127 @@
"""Data models for the disk reorganizer"""
from dataclasses import dataclass, field
from pathlib import Path
from datetime import datetime
from typing import Optional
@dataclass
class FileRecord:
"""Core file record with all metadata"""
path: Path
size: int
modified_time: float
created_time: float
disk_label: str
checksum: Optional[str] = None
status: str = 'indexed' # indexed, planned, moved, verified
category: Optional[str] = None
duplicate_of: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for serialization"""
return {
'path': str(self.path),
'size': self.size,
'modified_time': self.modified_time,
'created_time': self.created_time,
'disk_label': self.disk_label,
'checksum': self.checksum,
'status': self.status,
'category': self.category,
'duplicate_of': self.duplicate_of
}
@dataclass
class OperationRecord:
"""Record of a migration operation"""
source_path: Path
target_path: Path
operation_type: str # move, copy, hardlink, symlink
size: int = 0
status: str = 'pending' # pending, in_progress, completed, failed
error: Optional[str] = None
executed_at: Optional[datetime] = None
verified: bool = False
def to_dict(self) -> dict:
"""Convert to dictionary for serialization"""
return {
'source_path': str(self.source_path),
'target_path': str(self.target_path),
'operation_type': self.operation_type,
'size': self.size,
'status': self.status,
'error': self.error,
'executed_at': self.executed_at.isoformat() if self.executed_at else None,
'verified': self.verified
}
@dataclass
class DiskInfo:
"""Information about a disk/volume"""
name: str
device: str
mount_point: Path
total_size: int
used_size: int
free_size: int
fs_type: str
@property
def usage_percent(self) -> float:
"""Calculate usage percentage"""
if self.total_size == 0:
return 0.0
return (self.used_size / self.total_size) * 100
@dataclass
class MigrationPlan:
"""Complete migration plan"""
target_disk: str
destination_disks: list[str]
operations: list[OperationRecord]
total_size: int
file_count: int
created_at: datetime = field(default_factory=datetime.now)
def to_dict(self) -> dict:
"""Convert to dictionary for serialization"""
return {
'target_disk': self.target_disk,
'destination_disks': self.destination_disks,
'operations': [op.to_dict() for op in self.operations],
'total_size': self.total_size,
'file_count': self.file_count,
'created_at': self.created_at.isoformat()
}
@dataclass
class ProcessingStats:
"""Statistics for processing operations"""
files_processed: int = 0
bytes_processed: int = 0
files_succeeded: int = 0
files_failed: int = 0
start_time: datetime = field(default_factory=datetime.now)
@property
def elapsed_seconds(self) -> float:
"""Calculate elapsed time in seconds"""
return (datetime.now() - self.start_time).total_seconds()
@property
def files_per_second(self) -> float:
"""Calculate processing rate"""
elapsed = self.elapsed_seconds
return self.files_processed / elapsed if elapsed > 0 else 0.0
@property
def bytes_per_second(self) -> float:
"""Calculate throughput"""
elapsed = self.elapsed_seconds
return self.bytes_processed / elapsed if elapsed > 0 else 0.0