diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index edeafe7..b06aae8 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -91,6 +91,23 @@ jobs: fi echo "OK: chemavx/researchowl:${TAG} verified in registry" + - name: Run the suite inside the image + run: | + TAG=${{ steps.tag.outputs.TAG }} + BASE=gitea.gitea.svc.cluster.local:3000/chemavx/researchowl:${TAG} + # Contra la imagen recién construida, no contra el árbol de fuentes: + # la suite tiene que ver las librerías que se van a desplegar. Antes + # corría sólo en el portátil, con otro anthropic que el del pod, y por + # eso un kwarg que el SDK ya no aceptaba pasó meses sin que nada lo + # dijera. + docker buildx build \ + --builder ci-builder \ + --load \ + --build-arg BASE=${BASE} \ + -t researchowl-test:${TAG} \ + -f Dockerfile.test . + docker run --rm researchowl-test:${TAG} + - name: Update k8s manifests run: | pip3 install pyyaml -q diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..17e3278 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,22 @@ +# La imagen de runtime más los corredores de la suite. +# +# Parte de la imagen que se acaba de construir, no del árbol de fuentes, para +# que los tests vean las MISMAS librerías que se despliegan. Sin esto la suite +# corría en el portátil contra anthropic 0.102.0 mientras el pod ejecutaba +# 1.2.0, y así es como un `temperature` que el SDK ya no aceptaba vivió meses +# de reintentos que nunca ocurrieron. +# +# `tests/` no hace falta copiarlo: el Dockerfile de runtime hace `COPY . .` y ya +# viaja dentro. +ARG BASE +FROM ${BASE} + +RUN pip install --no-cache-dir pytest==9.0.3 pytest-asyncio==1.4.0 + +# `settings.telegram_bot_token` es obligatorio y en la imagen no hay `.env`: +# sin esto la suite no llega ni a recolectar. Es un valor de mentira a propósito +# — ningún test habla con Telegram. +ENV TELEGRAM_BOT_TOKEN=ci-dummy +ENV PYTEST_ADDOPTS="-p no:cacheprovider" + +CMD ["python", "-m", "pytest", "tests", "-q"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 1d6cf3f..d5ac543 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,11 @@ -# Solo para ejecutar la suite en local — NO va en la imagen (el Dockerfile -# instala requirements.txt y nada más; la CI no corre pytest). +# Los corredores de la suite. NO van en la imagen de runtime: los instala +# `Dockerfile.test`, que parte de la imagen ya construida para que los tests +# vean EXACTAMENTE las librerías que se despliegan. +# +# Pinneados por la misma razón que el resto: un suelo deja que una versión +# distinta entre sin commit, y entonces "pasa en mi máquina" deja de significar +# nada. Ver el comentario de `anthropic` en requirements.txt. # pip install -r requirements-dev.txt && make test -r requirements.txt -pytest>=8.0 -pytest-asyncio>=0.24 +pytest==9.0.3 +pytest-asyncio==1.4.0 diff --git a/requirements.txt b/requirements.txt index e6ab9d2..1285c02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,18 @@ numpy==1.26.4 scikit-learn==1.5.1 # Claude API (scoring) -anthropic>=0.40.0 +# +# Pinneado, no con suelo — y esta línea es la razón por la que el resto lo está. +# Era `>=0.40.0`, y con eso entró **anthropic 1.2.0** en producción sin un solo +# commit: una versión mayor que quitó `temperature` de `messages.create` (los +# parámetros de muestreo se fueron a `output_config`). El reintento estricto del +# SEO llevaba desde entonces muriendo con un TypeError que un `except` convertía +# en aviso. Nadie lo vio porque la suite corría en otra máquina con 0.102.0. +# +# 1.2.0 es lo que había instalado en el pod el 2026-09-02, medido con +# `pip freeze` dentro del contenedor. Para subirlo: cambiar aquí, y que la suite +# de la CI —que ahora corre DENTRO de la imagen— diga si vale. +anthropic==1.2.0 # PDF export markdown==3.10.2 diff --git a/tests/test_sdk_contract.py b/tests/test_sdk_contract.py new file mode 100644 index 0000000..755843a --- /dev/null +++ b/tests/test_sdk_contract.py @@ -0,0 +1,73 @@ +"""Lo que le pedimos al SDK de Anthropic contra lo que el SDK acepta. + +Este fichero existe por un fallo concreto: `src/seo/autofill.py` mandaba +`temperature=0.0` a `messages.create`, y anthropic 1.2.0 lo quitó de la firma +—los parámetros de muestreo se movieron a `output_config`—. La llamada moría +con un TypeError que un `except` convertía en aviso, así que el reintento +estricto del SEO llevaba meses sin ejecutarse. Nada lo vio: la importación no +falla, ningún test toca la API de verdad, y la suite corría en un portátil con +anthropic 0.102.0 mientras el pod ejecutaba 1.2.0. + +Comprobar el caso arreglado no habría bastado. Lo que hace falta es cerrar la +CLASE: se leen TODAS las llamadas del árbol con `ast` y se comparan sus kwargs +con la firma del SDK instalado. Un kwarg que el SDK ya no acepta falla aquí, en +el sitio donde está escrito, sin que nadie tenga que ejercitar ese camino. + +Junto con el pin de `requirements.txt` y el `pytest` que la CI corre DENTRO de +la imagen, esto pasa a ejecutarse contra el SDK que de verdad se despliega — +que es lo único que lo convierte en una prueba y no en un gesto. +""" +from __future__ import annotations + +import ast +import inspect +from pathlib import Path + +import pytest + +SRC = Path(__file__).resolve().parents[1] / "src" + + +def _llamadas_al_sdk() -> list[tuple[str, int, list[str], bool]]: + """(fichero, línea, kwargs, tiene_**kwargs) de cada `*.messages.create(...)`.""" + encontradas = [] + for fichero in sorted(SRC.rglob("*.py")): + arbol = ast.parse(fichero.read_text(encoding="utf-8"), filename=str(fichero)) + for nodo in ast.walk(arbol): + if not isinstance(nodo, ast.Call): + continue + fn = nodo.func + if not (isinstance(fn, ast.Attribute) and fn.attr == "create" + and isinstance(fn.value, ast.Attribute) + and fn.value.attr == "messages"): + continue + nombres = [k.arg for k in nodo.keywords if k.arg is not None] + estrella = any(k.arg is None for k in nodo.keywords) + encontradas.append((str(fichero.relative_to(SRC)), nodo.lineno, + nombres, estrella)) + return encontradas + + +def _firma() -> set[str]: + from anthropic import AsyncAnthropic + return set(inspect.signature( + AsyncAnthropic(api_key="test").messages.create).parameters) + + +def test_hay_llamadas_que_revisar(): + """Un barrido que no encuentra nada mide una lista vacía y pasa siempre.""" + assert len(_llamadas_al_sdk()) >= 5 + + +@pytest.mark.parametrize("fichero,linea,kwargs,estrella", _llamadas_al_sdk(), + ids=lambda v: str(v)) +def test_cada_llamada_usa_kwargs_que_el_sdk_acepta(fichero, linea, kwargs, estrella): + if estrella and not kwargs: + # Los kwargs se construyen aparte (p. ej. `_create_kwargs`): esa función + # tiene su propio test, que compara el diccionario ya montado. + pytest.skip(f"{fichero}:{linea} pasa **kwargs; cubierto en su propio test") + firma = _firma() + desconocidos = sorted(k for k in kwargs if k not in firma) + assert desconocidos == [], ( + f"{fichero}:{linea} pasa {desconocidos} a messages.create y el SDK " + f"instalado no lo acepta. Arregla la llamada, no relajes el assert.")