feat: empaquetado .exe con instalador y fix conexion SCAII
- launcher.py reescrito para PyInstaller (Voila in-process, kernel router) - build_exe.bat: script PyInstaller (onedir + collect-all dependencias Jupyter) - build_installer.bat + installer.iss: instalador Inno Setup - schema_registro_sqlite.sql: schema DataStage en SQLite - _make_conn_str ahora respeta SCAII_TRUSTED (Windows Auth) - .env.example: quitar variables DB_* (Postgres ya no usado por DataStage) - requirements.txt: quitar psycopg2-binary Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
208
app/launcher.py
208
app/launcher.py
@@ -1,10 +1,9 @@
|
||||
"""
|
||||
Launcher de la app GENPACT V2.
|
||||
Launcher de la app GENPACT V2 - Utilerias RECON 2.
|
||||
|
||||
Arranca un servidor Voila local en background y abre el browser apuntando al app.ipynb.
|
||||
Cuando se cierra la consola, el servidor de Voila se mata.
|
||||
|
||||
Funciona tanto desde Python directo como empaquetado con PyInstaller.
|
||||
Arranca Voila in-process (sin subprocess) para que funcione empaquetado con
|
||||
PyInstaller. Si el .exe es invocado como kernel de IPython, enruta al
|
||||
ipykernel_launcher en vez de relanzar Voila (evita el bucle infinito).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -12,36 +11,113 @@ import time
|
||||
import socket
|
||||
import threading
|
||||
import webbrowser
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Localizar app.ipynb y .env
|
||||
# ------------------------------------------------------------------
|
||||
def base_dir() -> Path:
|
||||
"""Carpeta donde estan los recursos de la app.
|
||||
- En modo PyInstaller (--onedir), sys._MEIPASS apunta a la carpeta temporal.
|
||||
- En modo dev, es la carpeta del script.
|
||||
"""
|
||||
# ============================================================
|
||||
# Diagnostico: escribir log al disco apenas arranque el launcher.
|
||||
# Si este archivo no aparece, el problema es PyInstaller / antivirus
|
||||
# bloqueando la extraccion antes de que Python siquiera empiece.
|
||||
# ============================================================
|
||||
def _diag_log(msg):
|
||||
try:
|
||||
log_path = os.path.join(os.path.expanduser('~'), 'utilerias_recon_boot.log')
|
||||
with open(log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(f'{time.strftime("%Y-%m-%d %H:%M:%S")} | {msg}\n')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_diag_log(f'=== LAUNCHER START ===')
|
||||
_diag_log(f'sys.executable={sys.executable}')
|
||||
_diag_log(f'sys.argv={sys.argv}')
|
||||
_diag_log(f'cwd={os.getcwd()}')
|
||||
_diag_log(f'frozen={getattr(sys, "frozen", False)}')
|
||||
|
||||
try:
|
||||
if sys.stdout is not None:
|
||||
print(f'[boot] launcher iniciando, sys.executable={sys.executable}', flush=True)
|
||||
print(f'[boot] argv={sys.argv}', flush=True)
|
||||
print(f'[boot] cwd={os.getcwd()}', flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cargar .env ANTES de cualquier cosa para que las variables
|
||||
# queden en os.environ y se hereden a Voila + kernels subprocess.
|
||||
# (En --onefile el cwd del kernel apunta al temp de PyInstaller,
|
||||
# no a la carpeta del .exe, asi que load_dotenv relativo falla.)
|
||||
# ============================================================
|
||||
def _exe_dir() -> Path:
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys.executable).parent
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
_env_file = _exe_dir() / '.env'
|
||||
if _env_file.exists():
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(_env_file, override=True)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Router de modo: detectar invocacion como kernel de Jupyter
|
||||
# jupyter_client spawnea: sys.executable -m ipykernel_launcher -f conn.json
|
||||
# En el .exe eso seria: UtileriasReconV2.exe -m ipykernel_launcher -f conn.json
|
||||
# ============================================================
|
||||
if 'ipykernel_launcher' in sys.argv:
|
||||
# En modo empaquetado, sys.stdout/stderr heredados del padre (Voila)
|
||||
# pueden ser invalidos para flush() y romper ipykernel.init_io.
|
||||
# ipykernel los reemplaza por canales ZMQ despues, asi que devnull es seguro.
|
||||
if getattr(sys, 'frozen', False):
|
||||
try:
|
||||
_devnull = open(os.devnull, 'w')
|
||||
sys.stdout = _devnull
|
||||
sys.stderr = _devnull
|
||||
except Exception:
|
||||
pass
|
||||
sys.argv = [a for a in sys.argv if a not in ('-m', 'ipykernel_launcher')]
|
||||
from ipykernel import kernelapp
|
||||
kernelapp.launch_new_instance()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Localizar app.ipynb y .env
|
||||
# ============================================================
|
||||
def resources_dir() -> Path:
|
||||
"""Recursos embebidos (app.ipynb bundleado). En PyInstaller usa _MEIPASS."""
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
return Path(sys._MEIPASS)
|
||||
return Path(sys.executable).parent if getattr(sys, 'frozen', False) \
|
||||
else Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def user_dir() -> Path:
|
||||
"""Archivos del usuario (.env). Siempre al lado del .exe."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
# PyInstaller --onedir: los datos quedan al lado del .exe
|
||||
return Path(sys.executable).parent
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
BASE = base_dir()
|
||||
APP_NB = BASE / 'app.ipynb'
|
||||
RES = resources_dir()
|
||||
USR = user_dir()
|
||||
|
||||
# Si el .env esta un nivel arriba (proyecto), copialo o setea el cwd
|
||||
if not (BASE / '.env').exists() and (BASE.parent / '.env').exists():
|
||||
os.chdir(BASE.parent)
|
||||
# Buscar app.ipynb: primero al lado del .exe (override), luego en recursos
|
||||
APP_NB = USR / 'app.ipynb'
|
||||
if not APP_NB.exists():
|
||||
APP_NB = RES / 'app.ipynb'
|
||||
|
||||
# cwd donde este el .env (para que python-dotenv lo encuentre)
|
||||
if (USR / '.env').exists():
|
||||
os.chdir(USR)
|
||||
elif (USR.parent / '.env').exists():
|
||||
os.chdir(USR.parent)
|
||||
else:
|
||||
os.chdir(BASE)
|
||||
os.chdir(USR)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Buscar puerto libre
|
||||
# ------------------------------------------------------------------
|
||||
def find_free_port(preferido: int = 8866) -> int:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
@@ -50,7 +126,6 @@ def find_free_port(preferido: int = 8866) -> int:
|
||||
return preferido
|
||||
except OSError:
|
||||
sock.close()
|
||||
# Si el preferido esta ocupado, deja que el SO asigne uno
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind(('127.0.0.1', 0))
|
||||
p = s.getsockname()[1]
|
||||
@@ -58,63 +133,66 @@ def find_free_port(preferido: int = 8866) -> int:
|
||||
return p
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Arrancar Voila
|
||||
# ------------------------------------------------------------------
|
||||
def fatal(msg: str):
|
||||
"""Muestra error y sale. Si no hay consola, usa MessageBox de Windows."""
|
||||
try:
|
||||
if sys.stdout is not None:
|
||||
print(f'ERROR: {msg}')
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import tkinter
|
||||
from tkinter import messagebox
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
messagebox.showerror('Utilerias RECON 2', msg)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not APP_NB.exists():
|
||||
print(f'ERROR: no se encuentra {APP_NB}')
|
||||
sys.exit(1)
|
||||
fatal(f'No se encuentra app.ipynb en {APP_NB}')
|
||||
|
||||
port = find_free_port(8866)
|
||||
# Cuando Voila apunta a un notebook especifico, lo sirve en la raiz "/"
|
||||
url = f'http://127.0.0.1:{port}/'
|
||||
|
||||
print('=' * 60)
|
||||
print(' GENPACT V2 - Sistema de Descargas SCAII')
|
||||
print('=' * 60)
|
||||
print(f' Iniciando servidor Voila en puerto {port}...')
|
||||
print(f' URL: {url}')
|
||||
print(' Cierra esta ventana para detener la app.')
|
||||
print('=' * 60)
|
||||
if sys.stdout is not None:
|
||||
print('=' * 60)
|
||||
print(' GENPACT V2 - Sistema de Descargas SCAII')
|
||||
print('=' * 60)
|
||||
print(f' URL: {url}')
|
||||
print(' Cierra esta ventana para detener la app.')
|
||||
print('=' * 60)
|
||||
|
||||
# Construir comando de Voila
|
||||
if getattr(sys, 'frozen', False):
|
||||
# En modo empaquetado, llamamos al binario de python integrado
|
||||
cmd = [sys.executable, '-m', 'voila',
|
||||
str(APP_NB),
|
||||
f'--port={port}',
|
||||
'--no-browser',
|
||||
'--Voila.ip=127.0.0.1',
|
||||
'--strip_sources=True']
|
||||
else:
|
||||
cmd = [sys.executable, '-m', 'voila',
|
||||
str(APP_NB),
|
||||
f'--port={port}',
|
||||
'--no-browser',
|
||||
'--Voila.ip=127.0.0.1',
|
||||
'--strip_sources=True']
|
||||
|
||||
# Lanzar proceso
|
||||
proc = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr)
|
||||
|
||||
# Abrir browser cuando el server este listo
|
||||
# Abrir browser cuando Voila acepte conexiones
|
||||
def _open():
|
||||
for _ in range(60):
|
||||
for _ in range(120):
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=0.5):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.5)
|
||||
webbrowser.open(url)
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
# Lanzar Voila in-process (sin subprocess)
|
||||
sys.argv = [
|
||||
'voila',
|
||||
str(APP_NB),
|
||||
f'--port={port}',
|
||||
'--no-browser',
|
||||
'--Voila.ip=127.0.0.1',
|
||||
'--strip_sources=True',
|
||||
]
|
||||
try:
|
||||
proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
print('\nDeteniendo Voila...')
|
||||
proc.terminate()
|
||||
from voila.app import Voila
|
||||
Voila.launch_instance()
|
||||
except SystemExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
fatal(f'Voila fallo al iniciar: {e}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user