addons-cm/tools/ci/run_tests.py
GitHub Copilot 87c2d1d1d3
Some checks failed
CI / lint (pull_request) Successful in 31s
CI / test (pull_request) Failing after 11m29s
CI / review (pull_request) Has been skipped
[IMP] ci: test the ISOLATE addons in their own database
website_sale_disable_cart removes the cart from every website once
installed, which breaks website_membership_signup_required when CI installs
everything together. The repo is shared by clients that never install them
together: run_tests.py now tests each ISOLATE addon in its own database,
after the rest, and ci.yml isolates website_sale_disable_cart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:04:56 +02:00

242 lines
8.5 KiB
Python
Executable file

#!/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. Los de ISOLATE se prueban cada uno en su propia base de datos, después
del resto: son los que cambian el comportamiento de otros con solo estar
instalados (website_sale_disable_cart quita el carrito a todos los sitios web),
y el repo lo comparten varios clientes que no los instalan juntos.
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"))
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]
skip = env_list("ODOO_SRC_SKIP")
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 env_list(name):
return set(os.environ.get(name, "").replace(",", " ").split())
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, db, *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, runs):
"""Deja en BOT_POT_DIR/<commit> el .pot de cada addon ya instalado.
`runs` es una lista de (base de datos, addons instalados en ella).
"""
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)
_logger.info("Exportando los .pot a %s", out)
for db, addons in runs:
cmd = [
sys.executable,
str(odoo_bin),
"shell",
f"--addons-path={','.join(map(str, paths))}",
f"--database={db}",
"--no-http",
"--log-level=warn",
]
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 run_tests(odoo_bin, paths, manifests, db, addons):
"""Crea `db` con las dependencias de `addons` y los instala con sus tests."""
dependencies = sorted(
{dep for addon in addons for dep in manifests[addon].get("depends", [])}
- set(addons)
)
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, db, *init):
sys.exit("No se ha podido crear la base de datos con las dependencias")
_logger.info("Probando: %s", ", ".join(addons))
return odoo(odoo_bin, paths, db, "--init", ",".join(addons), "--test-enable")
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 = env_list("EXCLUDE")
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."
)
isolate = env_list("ISOLATE")
together = [addon for addon in addons if addon not in isolate]
runs = [(DB, together)] if together else []
runs += [(f"{DB}_{addon}", [addon]) for addon in addons if addon in isolate]
wait_for_postgres()
failed = []
for db, run_addons in runs:
if run_tests(odoo_bin, paths, manifests, db, run_addons):
failed.append(db)
if failed:
sys.exit(f"Fallan los tests en: {', '.join(failed)}")
if 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, runs)
except OSError as e:
_logger.warning("No se han podido exportar los .pot: %s", e)
if __name__ == "__main__":
main()