"""Creación automática de config/ y carpetas de trabajo.""" import hashlib import shutil import sys from pathlib import Path from typing import Optional from .. import __version__ from ..constants import ( APP_DIR, BUNDLE_DIR, BUNDLED_SOURCE_7ZIP, BUNDLED_SOURCE_ODBC, BUNDLED_STAMP_PATH, CONFIG_DIR, DATA_DIR, DIR_ENTRADA, DIR_FALLADOS, DIR_PROCESADOS, DIR_TEMP, ENV_PATH, IS_WINDOWS, LOGS_DIR, ODBC_DIR, SEVEN_ZIP_DIR, VERSION_PATH, ) from ..db.database import DatabaseManager from .env_loader import render_env_template def _bundled_resource(rel_path: str) -> Optional[Path]: """ Recurso empaquetado: primero dentro del onefile (BUNDLE_DIR), luego junto al código en desarrollo. Mismo orden de preferencia que usa _write_env_if_missing. """ for base in (BUNDLE_DIR, APP_DIR): candidate = base / rel_path if candidate.is_file(): return candidate return None def _read_stamp(path: Path) -> str: try: return path.read_text(encoding="utf-8").strip() except OSError: return "" def _write_stamp(path: Path, content: str) -> None: """Escribe un sello solo si cambió, para no tocar disco en cada arranque.""" value = content.strip() if _read_stamp(path) == value: return try: path.write_text(value, encoding="utf-8") except OSError: # Los sellos son informativos: si config/ no es escribible, el arranque sigue. # La consecuencia es re-copiar las deps embebidas en el próximo arranque. pass def _bundled_deps_stamp() -> str: """ Huella de las dependencias embebidas en ESTE build: el sha256 de packaging/bundled-versions.json, que es el archivo que fija las versiones de 7-Zip, del driver ODBC y de unixODBC. Si no viene empaquetado, cae a la versión de la app. """ manifest = _bundled_resource("packaging/bundled-versions.json") if manifest is not None: try: return "sha256:" + hashlib.sha256(manifest.read_bytes()).hexdigest() except OSError: pass return f"app:{__version__}" def _backup_before_refresh(dest: Path) -> None: """ Aparta el contenido actual a `.bak` antes de re-copiarlo. El re-despliegue usa copytree(dirs_exist_ok=True), que SOBRESCRIBE los archivos: si el operador editó a mano algo como odbcinst.ini, se perdería en la primera ejecución tras actualizar y sin aviso. Esa edición no se puede reconstruir, así que se conserva. Un solo respaldo rotatorio, sin fecha: estas carpetas viven en servidores que almacenan respaldos de bases y no conviene acumular una copia del driver ODBC por cada actualización. """ backup = dest.with_name(f"{dest.name}.bak") try: if backup.exists(): shutil.rmtree(backup, ignore_errors=True) shutil.copytree(dest, backup, dirs_exist_ok=True) except OSError: # El respaldo es una red de seguridad, no un requisito: si el disco no da o los # permisos no alcanzan, el refresco debe seguir su curso. pass def _copy_bundled_tree(src: Path, dest: Path, refresh: bool) -> None: """ Despliega 7-Zip/ODBC del bundle a config/. Copia si el destino está vacío y RE-copia cuando refresh es True, es decir cuando este build trae otras versiones embebidas: una actualización con driver ODBC nuevo debe reemplazar el viejo, no conservarlo. Antes de re-copiar sobre contenido existente se aparta una copia a `.bak`, porque el copytree sobrescribe y las ediciones manuales del operador no son reconstruibles. dest puede existir pero VACÍO: ensure_runtime_layout crea ODBC_DIR/SEVEN_ZIP_DIR antes de llamar aquí, así que la condición mira el contenido, no la existencia. """ if not src.is_dir(): return has_content = dest.exists() and any(dest.iterdir()) if has_content and not refresh: return if has_content and refresh: _backup_before_refresh(dest) dest.mkdir(parents=True, exist_ok=True) shutil.copytree(src, dest, dirs_exist_ok=True) def _ensure_seven_zip_executable() -> None: """ Asegura el bit de ejecución del 7-Zip embebido en Linux/macOS. shutil.copytree suele preservar los permisos, pero esto es una salvaguarda barata: si el binario quedó sin 'x', la extracción fallaría con PermissionError. """ if IS_WINDOWS: return for exe_name in ("7zz", "7za", "7z"): exe_path = SEVEN_ZIP_DIR / exe_name if exe_path.is_file(): try: exe_path.chmod(0o755) except OSError: pass def _write_env_if_missing() -> None: if ENV_PATH.exists(): return template_path = BUNDLE_DIR / "packaging" / "templates" / "env.default" if not template_path.is_file(): template_path = APP_DIR / "packaging" / "templates" / "env.default" if template_path.is_file(): content = template_path.read_text(encoding="utf-8") content = content.replace("{APP_DIR}", str(APP_DIR)) else: content = render_env_template(APP_DIR) ENV_PATH.write_text(content, encoding="utf-8") try: ENV_PATH.chmod(0o600) except OSError: pass def ensure_runtime_layout() -> None: """ Idempotente: crea config/, carpetas de trabajo y .env si no existen. No sobrescribe .env ni app.db existentes. """ for directory in ( CONFIG_DIR, DATA_DIR, LOGS_DIR, ODBC_DIR, SEVEN_ZIP_DIR, DIR_ENTRADA, DIR_PROCESADOS, DIR_FALLADOS, DIR_TEMP, ): directory.mkdir(parents=True, exist_ok=True) _write_env_if_missing() # Los sellos se ubican bajo el CONFIG_DIR vigente (no la ruta absoluta precalculada) # para que respeten el monkeypatch de las pruebas y no escriban en el config/ real. deps_stamp_path = CONFIG_DIR / BUNDLED_STAMP_PATH.name version_stamp_path = CONFIG_DIR / VERSION_PATH.name # Las deps embebidas se re-despliegan cuando el build trae otras versiones. El sello # se escribe DESPUÉS de copiar: si la copia falla a medias, el próximo arranque lo # reintenta en lugar de quedar marcado como al día. deps_stamp = _bundled_deps_stamp() refresh_deps = _read_stamp(deps_stamp_path) != deps_stamp _copy_bundled_tree(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR, refresh_deps) _copy_bundled_tree(BUNDLED_SOURCE_ODBC, ODBC_DIR, refresh_deps) _ensure_seven_zip_executable() if refresh_deps: _write_stamp(deps_stamp_path, deps_stamp) # Sello de versión: lo lee el instalador remoto del PANEL por SFTP para verificar el # despliegue (en Windows el .exe es console=False y no tiene stdout confiable). _write_stamp(version_stamp_path, __version__) from ..constants import DB_PATH if not DB_PATH.exists(): DatabaseManager(DB_PATH) if getattr(sys, "frozen", False): marker = CONFIG_DIR / ".bootstrap_ok" if not marker.exists(): marker.write_text("ok", encoding="utf-8")