Same system as ecocentral-18 (see its docs/CI-CD.md): - ci.yml: lint (pre-commit), test (tools/ci/run_tests.py, OCA-style two-phase install) and review (PR bot, after lint and test). - issue-bot.yml: the issue bot answers issues or opens a PR on bot/issue-N. - pr-bot.yml: review when WIP is removed or the i18n label is added. - .gitea/issue-bot.md: context for the bots; this repo is shared by several clients. The bot scripts are copies of ecocentral's. Only the defaults differ (repo, token and state files, i18n languages es,eu,ca,gl) along with the [I18N] commit message and the references to the runner's sources.txt. The jobs run on a second forgejo-runner registration (CriptoMart org) on ecocentral's runner machine, sharing its image, mounts and cache. The runner files (Dockerfile, update.sh, sources.txt) live in ecocentral. Specific to this repo: - ODOO_SRC_SKIP: the runner's /odoo-src/18.0 holds a clone of addons-cm, so test skips it and checks the PR checkout instead. - BOT_BASE_BRANCH "18.0": the repo's default branch is 16.0. - test PostgreSQL on port 54330: ecocentral's runs on 54329 and a test job from each repo can run at the same time. The lint job fails until the repo is cleaned up with pre-commit run --all-files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
4.6 KiB
Python
138 lines
4.6 KiB
Python
"""Traducciones de los addons que toca un PR.
|
|
|
|
Qué términos son traducibles y a qué registro pertenece cada uno (las líneas
|
|
«#:» del .po, sin las que Odoo no carga la traducción) solo lo sabe Odoo, así
|
|
que el .pot no se escribe a mano: lo exporta el job de tests del CI
|
|
(tools/ci/run_tests.py) y aquí se recoge. Con él se preparan los .po de cada
|
|
idioma; rellenar los msgstr vacíos es trabajo de Claude, y check() repasa lo
|
|
que deja.
|
|
"""
|
|
|
|
import re
|
|
|
|
import polib
|
|
|
|
# Odoo pone la fecha de exportación en la cabecera: cambiaría en cada PR.
|
|
VOLATILE_METADATA = ("POT-Creation-Date", "PO-Revision-Date")
|
|
PLURAL_FORMS = "nplurals=2; plural=(n != 1);"
|
|
PLACEHOLDER = re.compile(r"%\(\w+\)[sdfr]|%[sdfr]|\{\w*\}")
|
|
|
|
|
|
def changed_addons(repo_dir, files):
|
|
"""Addons con algún fichero modificado fuera de su carpeta i18n."""
|
|
addons = set()
|
|
for name in files:
|
|
addon, _, rest = name.partition("/")
|
|
if rest and not rest.startswith("i18n/"):
|
|
if (repo_dir / addon / "__manifest__.py").is_file():
|
|
addons.add(addon)
|
|
return sorted(addons)
|
|
|
|
|
|
def terms(po):
|
|
return [(e.msgid, e.msgstr, e.comment, e.occurrences, e.flags) for e in po]
|
|
|
|
|
|
def pending(po):
|
|
return sum(1 for entry in po if not entry.msgstr)
|
|
|
|
|
|
def placeholders(text):
|
|
return sorted(PLACEHOLDER.findall(text))
|
|
|
|
|
|
def write_if_changed(po, path):
|
|
"""No reescribe un fichero que ya dice lo mismo: evita diffs de formato."""
|
|
if path.is_file() and terms(polib.pofile(str(path))) == terms(po):
|
|
return
|
|
path.parent.mkdir(exist_ok=True)
|
|
po.save(str(path))
|
|
|
|
|
|
def merge(po, pot):
|
|
"""Deja en el .po los términos del .pot, y solo esos, con sus referencias."""
|
|
po.merge(pot)
|
|
for entry in po.obsolete_entries():
|
|
po.remove(entry)
|
|
po.sort(key=lambda entry: entry.msgid)
|
|
|
|
|
|
def prepare(addon_dir, pot_file, langs):
|
|
"""Pone al día el .pot del addon y un .po por idioma.
|
|
|
|
Devuelve el estado que necesita check(), o None si el addon no tiene
|
|
términos traducibles.
|
|
"""
|
|
pot = polib.pofile(str(pot_file))
|
|
if not pot:
|
|
return None
|
|
for key in VOLATILE_METADATA:
|
|
pot.metadata.pop(key, None)
|
|
i18n_dir = addon_dir / "i18n"
|
|
write_if_changed(pot, i18n_dir / f"{addon_dir.name}.pot")
|
|
files = {}
|
|
for lang in langs:
|
|
path = i18n_dir / f"{lang}.po"
|
|
new = not path.is_file()
|
|
if new:
|
|
po = polib.POFile()
|
|
po.header = pot.header
|
|
po.metadata = dict(pot.metadata, Language=lang)
|
|
po.metadata["Plural-Forms"] = PLURAL_FORMS
|
|
else:
|
|
po = polib.pofile(str(path))
|
|
merge(po, pot)
|
|
write_if_changed(po, path)
|
|
files[lang] = {
|
|
"path": path,
|
|
"new": new,
|
|
"pending": pending(po),
|
|
"backup": path.read_text(),
|
|
}
|
|
return {"pot": pot, "files": files}
|
|
|
|
|
|
def check(state):
|
|
"""Repasa los .po después de que Claude los haya rellenado.
|
|
|
|
Anota en cada fichero cuántas traducciones se han añadido (`done`) y cuántas
|
|
faltan (`pending`), y devuelve los avisos que deben salir en la revisión.
|
|
"""
|
|
notes = []
|
|
for info in state["files"].values():
|
|
path = info["path"]
|
|
name = f"{path.parent.parent.name}/i18n/{path.name}"
|
|
try:
|
|
po = polib.pofile(str(path)) if path.is_file() else None
|
|
except (OSError, UnicodeDecodeError):
|
|
po = None
|
|
if po is None:
|
|
notes.append(
|
|
f"`{name}` quedó ilegible y se han descartado sus traducciones."
|
|
)
|
|
path.write_text(info["backup"])
|
|
po = polib.pofile(str(path))
|
|
merge(po, state["pot"])
|
|
before = {e.msgid: e.msgstr for e in polib.pofile(info["backup"])}
|
|
broken = 0
|
|
for entry in po:
|
|
if not entry.msgstr or entry.msgstr == before.get(entry.msgid):
|
|
continue
|
|
# Una traducción con otros marcadores falla al formatear el texto.
|
|
if placeholders(entry.msgstr) != placeholders(entry.msgid):
|
|
entry.msgstr = ""
|
|
broken += 1
|
|
if broken:
|
|
notes.append(
|
|
f"`{name}`: {broken} traducciones descartadas porque no "
|
|
"conservaban los marcadores (`%s`, `%(nombre)s`) del original."
|
|
)
|
|
info["done"] = info["pending"] - pending(po)
|
|
info["pending"] = pending(po)
|
|
if info["new"] and info["pending"] == len(po):
|
|
# Un .po recién creado y sin ninguna traducción no aporta nada.
|
|
path.unlink()
|
|
info["removed"] = True
|
|
elif path.read_text() != info["backup"]:
|
|
po.save(str(path))
|
|
return notes
|