#!/usr/bin/env python3 """Tests de los addons del repo, con el mismo esquema que el CI de la OCA. Primero crea una base de datos con las dependencias de los addons y después instala los addons con --test-enable: así solo corren los tests del repo, no los del core ni los de la OCA. tools/ci/run_tests.py [addon ...] Sin argumentos prueba todos los addons instalables del repo menos los de EXCLUDE. Odoo y los repos de los que dependen los addons se toman de ODOO_SRC (un checkout por carpeta; en el runner es un montaje, ver tools/issue_bot/README.md), menos las carpetas de ODOO_SRC_SKIP, y la conexión a PostgreSQL, de las variables PG*. ODOO_SRC_SKIP existe porque el runner es compartido: su ODOO_SRC trae un clon de este mismo repo para los repos que dependen de él, y aquí se prueba el checkout del PR, no ese clon de la semana pasada. Si los tests pasan y está definida BOT_POT_DIR, deja ahí el .pot de cada addon para el bot de revisión de PR (tools/pr_bot), que así no tiene que instalar los addons otra vez. """ import ast import logging import os import shutil import subprocess import sys import time from pathlib import Path REPO = Path(__file__).resolve().parents[2] ODOO_SRC = Path(os.environ.get("ODOO_SRC", "/odoo-src/18.0")) SKIP = set(os.environ.get("ODOO_SRC_SKIP", "").replace(",", " ").split()) DB = os.environ.get("PGDATABASE", "test") POT_DIR = os.environ.get("BOT_POT_DIR") POT_KEEP_DAYS = 14 # Se le pasa a `odoo shell` por la entrada estándar: un solo arranque de Odoo # para todos los addons, en lugar de un --i18n-export por addon. EXPORT_POTS = """ from odoo.tools.translate import trans_export for addon in {addons!r}: with open({out!r} + "/" + addon + ".pot", "wb") as pot: trans_export(None, [addon], pot, "po", env.cr) """ _logger = logging.getLogger("ci") def find_sources(): """Devuelve odoo-bin y el addons_path: el repo y lo que haya en ODOO_SRC.""" odoo_bin, paths = None, [REPO] for src in sorted(ODOO_SRC.iterdir()): if src.name in SKIP: _logger.info("Se omite %s (ODOO_SRC_SKIP)", src) elif (src / "odoo-bin").is_file(): odoo_bin = src / "odoo-bin" paths += [src / "addons", src / "odoo" / "addons"] elif any(src.glob("*/__manifest__.py")): paths.append(src) if not odoo_bin: sys.exit(f"No hay un checkout de Odoo en {ODOO_SRC}") return odoo_bin, paths def read_manifests(paths): manifests = {} for path in paths: for manifest in sorted(path.glob("*/__manifest__.py")): # Como en Odoo, gana el primer addons_path que trae el addon. manifests.setdefault( manifest.parent.name, ast.literal_eval(manifest.read_text()) ) return manifests def missing_dependencies(addon, manifests): missing, seen, todo = set(), set(), [addon] while todo: name = todo.pop() if name in seen: continue seen.add(name) manifest = manifests.get(name) if not manifest or not manifest.get("installable", True): missing.add(name) else: todo += manifest.get("depends", []) return missing def wait_for_postgres(timeout=60): deadline = time.monotonic() + timeout while subprocess.run(["pg_isready", "--quiet"], check=False).returncode: if time.monotonic() > deadline: sys.exit("PostgreSQL no responde") time.sleep(1) def odoo(odoo_bin, paths, *args): cmd = [ sys.executable, str(odoo_bin), f"--addons-path={','.join(map(str, paths))}", f"--database={DB}", # Con --test-enable Odoo levanta su servidor HTTP para los tours, y # el runner lanza el job en la red del host: sin esto escucharía en # todas las interfaces de la máquina. Los tests lo buscan en # 127.0.0.1. "--http-interface=127.0.0.1", "--stop-after-init", *args, ] _logger.info("+ %s", " ".join(cmd)) return subprocess.run(cmd, check=False).returncode def export_pots(odoo_bin, paths, addons): """Deja en BOT_POT_DIR/ el .pot de cada addon ya instalado.""" pot_dir = Path(POT_DIR) head = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=REPO, capture_output=True, text=True, check=False, ).stdout.strip() if not head: _logger.warning("No se exportan los .pot: no se sabe el commit") return out, tmp = pot_dir / head, pot_dir / f"{head}.tmp" for stale in (out, tmp): shutil.rmtree(stale, ignore_errors=True) tmp.mkdir(parents=True) cmd = [ sys.executable, str(odoo_bin), "shell", f"--addons-path={','.join(map(str, paths))}", f"--database={DB}", "--no-http", "--log-level=warn", ] _logger.info("Exportando los .pot a %s", out) script = EXPORT_POTS.format(addons=addons, out=str(tmp)) if subprocess.run(cmd, input=script, text=True, check=False).returncode: _logger.warning("No se han podido exportar los .pot") shutil.rmtree(tmp, ignore_errors=True) return tmp.rename(out) limit = time.time() - POT_KEEP_DAYS * 86400 for old in pot_dir.iterdir(): if old.stat().st_mtime < limit: shutil.rmtree(old, ignore_errors=True) def main(): logging.basicConfig(level=logging.INFO, format="%(message)s") odoo_bin, paths = find_sources() manifests = read_manifests(paths) repo_addons = sorted(m.parent.name for m in REPO.glob("*/__manifest__.py")) exclude = set(os.environ.get("EXCLUDE", "").replace(",", " ").split()) addons = sys.argv[1:] or [ addon for addon in repo_addons if manifests[addon].get("installable", True) and addon not in exclude ] unknown = sorted(set(addons) - set(repo_addons)) if unknown: sys.exit(f"No son addons del repo: {', '.join(unknown)}") # Odoo no falla si a un addon le falta una dependencia: lo deja sin # instalar y sus tests no corren. Se comprueba aquí para que el CI no # salga en verde sin haber probado nada. unmet = { addon: missing for addon in addons if (missing := missing_dependencies(addon, manifests)) } if unmet: for addon, missing in unmet.items(): _logger.error("%s: falta %s", addon, ", ".join(sorted(missing))) sys.exit( "Dependencias sin resolver: añade su repo al sources.txt del runner " "(tools/issue_bot/runner/ en el repo de ecocentral) o el addon a " "EXCLUDE." ) dependencies = sorted( {dep for addon in addons for dep in manifests[addon].get("depends", [])} - set(addons) ) wait_for_postgres() subprocess.run(["dropdb", "--if-exists", DB], check=True) _logger.info("Instalando las dependencias: %s", ", ".join(dependencies)) init = ["--init", ",".join(dependencies), "--log-level=warn"] if odoo(odoo_bin, paths, *init): sys.exit("No se ha podido crear la base de datos con las dependencias") _logger.info("Probando: %s", ", ".join(addons)) result = odoo(odoo_bin, paths, "--init", ",".join(addons), "--test-enable") if not result and POT_DIR: # Que falte la caché no es un fallo de los tests: el bot de revisión # se queda sin las traducciones y lo dice. try: export_pots(odoo_bin, paths, addons) except OSError as e: _logger.warning("No se han podido exportar los .pot: %s", e) sys.exit(result) if __name__ == "__main__": main()