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>
242 lines
8.5 KiB
Python
Executable file
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()
|