refactor
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# core/archive.py
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import rarfile
|
||||
import shutil
|
||||
|
||||
|
||||
class ArchiveError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def detect_real_format(path: str) -> str | None:
|
||||
"""Devuelve 'zip', 'rar' o None según los magic bytes del archivo."""
|
||||
try:
|
||||
zipfile.ZipFile(path).close()
|
||||
return "zip"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
rarfile.RarFile(path).close()
|
||||
return "rar"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def open_archive(path: str):
|
||||
"""Devuelve un ZipFile o RarFile abierto en modo lectura."""
|
||||
fmt = detect_real_format(path)
|
||||
if fmt == "zip":
|
||||
return zipfile.ZipFile(path, "r")
|
||||
if fmt == "rar":
|
||||
return rarfile.RarFile(path, "r")
|
||||
raise ArchiveError(f"Formato desconocido o archivo corrupto: {path}")
|
||||
|
||||
|
||||
def extract_archive(path: str, dest_dir: str) -> str:
|
||||
"""Extrae el archivo en dest_dir. Devuelve dest_dir."""
|
||||
archive = open_archive(path)
|
||||
try:
|
||||
archive.extractall(dest_dir)
|
||||
finally:
|
||||
archive.close()
|
||||
return dest_dir
|
||||
|
||||
|
||||
def repack_as_cbz(source_dir: str, target_path: str) -> None:
|
||||
"""Empaqueta todos los archivos de source_dir en un CBZ (ZIP deflated)."""
|
||||
with zipfile.ZipFile(target_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, _, files in os.walk(source_dir):
|
||||
for f in files:
|
||||
full = os.path.join(root, f)
|
||||
rel = os.path.relpath(full, source_dir)
|
||||
zf.write(full, rel)
|
||||
+4
-2
@@ -4,12 +4,14 @@ import os
|
||||
import shutil
|
||||
from core.paths import get_project_root
|
||||
|
||||
|
||||
def move_to_backup(path):
|
||||
"""Mueve un archivo al directorio centralizado /backup."""
|
||||
"""Mueve un archivo al directorio centralizado /backup sin sobrescribir."""
|
||||
from core.collision import safe_backup_name
|
||||
root = get_project_root()
|
||||
backup_dir = os.path.join(root, "backup")
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
target = os.path.join(backup_dir, os.path.basename(path))
|
||||
target = safe_backup_name(path)
|
||||
shutil.move(path, target)
|
||||
return target
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# core/collision.py
|
||||
|
||||
import os
|
||||
from core.paths import get_project_root
|
||||
|
||||
|
||||
class CollisionPolicy:
|
||||
ABORT = "abort" # comportamiento estricto actual
|
||||
BACKUP = "backup" # mueve el existente a backup/ y sigue
|
||||
RENAME = "rename" # añade sufijo _1, _2... hasta nombre libre
|
||||
|
||||
|
||||
def safe_backup_name(path: str) -> str:
|
||||
"""
|
||||
Devuelve una ruta sin colisión dentro de backup/.
|
||||
Añade sufijo _1, _2... si el nombre base ya existe.
|
||||
"""
|
||||
root = get_project_root()
|
||||
backup_dir = os.path.join(root, "backup")
|
||||
base = os.path.basename(path)
|
||||
name, ext = os.path.splitext(base)
|
||||
candidate = os.path.join(backup_dir, base)
|
||||
counter = 1
|
||||
while os.path.exists(candidate):
|
||||
candidate = os.path.join(backup_dir, f"{name}_{counter}{ext}")
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def resolve_collision(target_path: str, policy: str = CollisionPolicy.ABORT) -> str:
|
||||
"""
|
||||
Comprueba si target_path existe y aplica la política:
|
||||
ABORT → lanza FileExistsError
|
||||
BACKUP → mueve el existente a backup/ y devuelve target_path
|
||||
RENAME → devuelve una ruta alternativa libre (target_1.cbz, ...)
|
||||
Devuelve la ruta segura donde escribir.
|
||||
"""
|
||||
if not os.path.exists(target_path):
|
||||
return target_path
|
||||
|
||||
if policy == CollisionPolicy.ABORT:
|
||||
raise FileExistsError(
|
||||
f"El archivo destino ya existe y no se sobrescribirá: {target_path}"
|
||||
)
|
||||
|
||||
if policy == CollisionPolicy.BACKUP:
|
||||
from core.backup import move_to_backup
|
||||
move_to_backup(target_path)
|
||||
return target_path
|
||||
|
||||
if policy == CollisionPolicy.RENAME:
|
||||
base, ext = os.path.splitext(target_path)
|
||||
counter = 1
|
||||
candidate = f"{base}_{counter}{ext}"
|
||||
while os.path.exists(candidate):
|
||||
counter += 1
|
||||
candidate = f"{base}_{counter}{ext}"
|
||||
return candidate
|
||||
|
||||
raise ValueError(f"Política de colisión desconocida: {policy}")
|
||||
@@ -0,0 +1,94 @@
|
||||
# core/pipeline.py
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from core.archive import detect_real_format, extract_archive, repack_as_cbz, ArchiveError
|
||||
from core.collision import CollisionPolicy, resolve_collision
|
||||
from core.result import ComicResult, StepResult
|
||||
from processors.validator import validate_archive
|
||||
from processors.cleaner import clean_directory
|
||||
from processors.converter import needs_conversion, conversion_step_result
|
||||
|
||||
|
||||
class Pipeline:
|
||||
def __init__(
|
||||
self,
|
||||
steps: list,
|
||||
desired_format: str = "cbz",
|
||||
collision_policy: str = CollisionPolicy.ABORT,
|
||||
dry_run: bool = False,
|
||||
):
|
||||
self.steps = steps
|
||||
self.desired_format = desired_format
|
||||
self.collision_policy = collision_policy
|
||||
self.dry_run = dry_run
|
||||
|
||||
def run(self, path: str) -> ComicResult:
|
||||
step_results = []
|
||||
|
||||
# 1. Validar siempre, antes de extraer
|
||||
val = validate_archive(path)
|
||||
step_results.append(val)
|
||||
if val.errors:
|
||||
return ComicResult(original_path=path, final_path=None, steps=step_results)
|
||||
|
||||
real_format = detect_real_format(path)
|
||||
|
||||
# 2. Extraer una sola vez
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
extract_archive(path, temp_dir)
|
||||
|
||||
# 3. Aplicar cada step sobre el directorio temporal
|
||||
any_changed = False
|
||||
|
||||
if "clean" in self.steps:
|
||||
clean_result = clean_directory(temp_dir)
|
||||
step_results.append(clean_result)
|
||||
if clean_result.changed:
|
||||
any_changed = True
|
||||
|
||||
if "convert" in self.steps:
|
||||
conv_result = conversion_step_result(real_format, self.desired_format)
|
||||
step_results.append(conv_result)
|
||||
if conv_result.errors:
|
||||
return ComicResult(
|
||||
original_path=path, final_path=None, steps=step_results
|
||||
)
|
||||
if conv_result.changed:
|
||||
any_changed = True
|
||||
|
||||
# 4. Reempaquetar si hubo cambios o conversión de formato
|
||||
needs_repack = any_changed or (
|
||||
"convert" in self.steps
|
||||
and needs_conversion(real_format, self.desired_format)
|
||||
)
|
||||
|
||||
if not needs_repack:
|
||||
return ComicResult(
|
||||
original_path=path, final_path=path, steps=step_results
|
||||
)
|
||||
|
||||
base, _ = os.path.splitext(path)
|
||||
target_path = f"{base}.{self.desired_format}"
|
||||
|
||||
if not self.dry_run:
|
||||
safe_target = resolve_collision(target_path, self.collision_policy)
|
||||
repack_as_cbz(temp_dir, safe_target)
|
||||
# Eliminar original si el nombre cambió
|
||||
if safe_target != path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
else:
|
||||
safe_target = target_path
|
||||
|
||||
except (ArchiveError, FileExistsError, OSError) as exc:
|
||||
step_results.append(
|
||||
StepResult(step="repack", changed=False, errors=[str(exc)])
|
||||
)
|
||||
return ComicResult(original_path=path, final_path=None, steps=step_results)
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
return ComicResult(original_path=path, final_path=safe_target, steps=step_results)
|
||||
@@ -0,0 +1,47 @@
|
||||
# core/result.py
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
step: str # "validate" | "clean" | "convert" | ...
|
||||
changed: bool
|
||||
details: list = field(default_factory=list) # e.g. ["Eliminado: thumbs.db"]
|
||||
warnings: list = field(default_factory=list)
|
||||
errors: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComicResult:
|
||||
original_path: str
|
||||
final_path: str | None # None si se abortó
|
||||
steps: list = field(default_factory=list)
|
||||
|
||||
def ok(self) -> bool:
|
||||
return all(not s.errors for s in self.steps)
|
||||
|
||||
def summary(self) -> str:
|
||||
if not self.ok():
|
||||
errors = [e for s in self.steps for e in s.errors]
|
||||
return f"ERROR [{self.original_path}]: {'; '.join(errors)}"
|
||||
changed_steps = [s.step for s in self.steps if s.changed]
|
||||
if changed_steps:
|
||||
dest = self.final_path or self.original_path
|
||||
return f"OK [{self.original_path}] → {dest} ({', '.join(changed_steps)})"
|
||||
return f"OK [{self.original_path}] (sin cambios)"
|
||||
|
||||
def full_report(self) -> str:
|
||||
lines = [f"Cómic: {self.original_path}"]
|
||||
for s in self.steps:
|
||||
status = "CAMBIADO" if s.changed else "sin cambios"
|
||||
lines.append(f" [{s.step}] {status}")
|
||||
for d in s.details:
|
||||
lines.append(f" - {d}")
|
||||
for w in s.warnings:
|
||||
lines.append(f" AVISO: {w}")
|
||||
for e in s.errors:
|
||||
lines.append(f" ERROR: {e}")
|
||||
if self.final_path:
|
||||
lines.append(f" Resultado final: {self.final_path}")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user