diff --git a/enlace/config/__init__.py b/enlace/config/__init__.py index 3b57d7c..a23e64a 100644 --- a/enlace/config/__init__.py +++ b/enlace/config/__init__.py @@ -4,6 +4,7 @@ Todo hiperparámetro, ruta y umbral vive en `configs/*.yaml`, se compone por capas y se valida con pydantic antes de que arranque cualquier proceso largo. """ +from enlace.config.env import load_dotenv from enlace.config.load import ( load_agent_config, load_config, @@ -36,6 +37,7 @@ __all__ = [ "TrainConfig", "load_agent_config", "load_config", + "load_dotenv", "load_config_from_argv", "load_distill_config", ] diff --git a/enlace/config/env.py b/enlace/config/env.py new file mode 100644 index 0000000..e203c10 --- /dev/null +++ b/enlace/config/env.py @@ -0,0 +1,51 @@ +"""Carga de `.env` para los entrypoints de Python. + +`scripts/remote.sh` hace `source .env`, pero los procesos de Python no heredan +eso: los SDKs leen variables de entorno, y sin este paso una clave correctamente +escrita en `.env` simplemente no existe para el proceso. El síntoma es un error +de autenticación con el archivo bien configurado, que es de los más molestos de +diagnosticar. + +Se implementa a mano en vez de agregar `python-dotenv`: son veinte líneas y +evita una dependencia más en el servidor de entrenamiento. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def load_dotenv(path: str | Path | None = None, *, override: bool = False) -> list[str]: + """Carga `.env` en `os.environ`. Devuelve los nombres definidos. + + Por defecto **no pisa** variables que ya existan en el entorno: una variable + exportada en la shell o inyectada por el orquestador gana sobre el archivo, + que es lo que se espera en producción. + + Nunca registra ni devuelve valores, solo nombres: estos archivos tienen + secretos y no deben terminar en un log. + """ + path = Path(path) if path is not None else REPO_ROOT / ".env" + if not path.is_file(): + return [] + + definidas: list[str] = [] + for linea in path.read_text(encoding="utf-8").splitlines(): + linea = linea.strip() + if not linea or linea.startswith("#") or "=" not in linea: + continue + clave, _, valor = linea.partition("=") + clave = clave.strip() + # `export FOO=bar` es válido en un .env pensado para `source`. + if clave.startswith("export "): + clave = clave[len("export ") :].strip() + valor = valor.strip().strip('"').strip("'") + if not clave or not valor: + continue + if override or clave not in os.environ: + os.environ[clave] = valor + definidas.append(clave) + return definidas diff --git a/enlace/data/distill.py b/enlace/data/distill.py index a612b35..b0aa72e 100644 --- a/enlace/data/distill.py +++ b/enlace/data/distill.py @@ -37,6 +37,7 @@ from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Iterable, Iterator, Protocol +from enlace.config.env import load_dotenv from enlace.config.schema import DistillConfig @@ -105,8 +106,19 @@ class ClienteAnthropic: raise DistillError( "falta el SDK: instalá el extra con `pip install -e '.[distill]'`" ) from None + + # El SDK lee la credencial del entorno, no de .env: sin esto una clave + # correctamente escrita en el archivo no existe para el proceso. + load_dotenv() self._anthropic = anthropic - self._client = anthropic.Anthropic() + try: + self._client = anthropic.Anthropic() + except Exception as exc: + raise DistillError( + f"no se pudo crear el cliente: {exc}\n" + "Definí ANTHROPIC_API_KEY en .env (sin el '#' adelante), " + "exportala en la shell, o corré `ant auth login`." + ) from None self.config = config def construir_params(self, peticion: Peticion) -> dict[str, Any]: diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 0000000..646ba5d --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,82 @@ +"""Carga de `.env`. + +Sin esto, una clave correctamente escrita en el archivo simplemente no existe +para el proceso de Python — los SDKs leen variables de entorno, no archivos. +El síntoma es un error de autenticación con el `.env` bien configurado. +""" + +from __future__ import annotations + +import os + +import pytest + +from enlace.config.env import load_dotenv + + +@pytest.fixture(autouse=True) +def entorno_limpio(monkeypatch): + for clave in ("PRUEBA_A", "PRUEBA_B", "PRUEBA_EXPORT", "PRUEBA_COMILLAS"): + monkeypatch.delenv(clave, raising=False) + + +def _env(tmp_path, contenido: str): + path = tmp_path / ".env" + path.write_text(contenido, encoding="utf-8") + return path + + +def test_define_las_variables(tmp_path): + definidas = load_dotenv(_env(tmp_path, "PRUEBA_A=uno\nPRUEBA_B=dos\n")) + assert set(definidas) == {"PRUEBA_A", "PRUEBA_B"} + assert os.environ["PRUEBA_A"] == "uno" + + +def test_ignora_comentarios_y_lineas_vacias(tmp_path): + contenido = "# PRUEBA_A=comentada\n\n \nPRUEBA_B=real\n" + assert load_dotenv(_env(tmp_path, contenido)) == ["PRUEBA_B"] + assert "PRUEBA_A" not in os.environ + + +def test_ignora_claves_sin_valor(tmp_path): + """`# ANTHROPIC_API_KEY=` descomentada pero vacía no debe definir nada: + una variable vacía autentica peor que una ausente, porque parece presente.""" + assert load_dotenv(_env(tmp_path, "PRUEBA_A=\n")) == [] + assert "PRUEBA_A" not in os.environ + + +def test_el_entorno_existente_gana_por_defecto(tmp_path, monkeypatch): + """Una variable exportada en la shell o inyectada por el orquestador debe + ganar sobre el archivo: es lo que se espera en producción.""" + monkeypatch.setenv("PRUEBA_A", "del-entorno") + load_dotenv(_env(tmp_path, "PRUEBA_A=del-archivo\n")) + assert os.environ["PRUEBA_A"] == "del-entorno" + + +def test_override_fuerza_el_archivo(tmp_path, monkeypatch): + monkeypatch.setenv("PRUEBA_A", "del-entorno") + load_dotenv(_env(tmp_path, "PRUEBA_A=del-archivo\n"), override=True) + assert os.environ["PRUEBA_A"] == "del-archivo" + + +def test_acepta_la_forma_export(tmp_path): + """El mismo archivo lo consume `source .env` desde scripts/remote.sh.""" + load_dotenv(_env(tmp_path, "export PRUEBA_EXPORT=valor\n")) + assert os.environ["PRUEBA_EXPORT"] == "valor" + + +def test_saca_las_comillas(tmp_path): + load_dotenv(_env(tmp_path, 'PRUEBA_COMILLAS="con espacios"\n')) + assert os.environ["PRUEBA_COMILLAS"] == "con espacios" + + +def test_archivo_ausente_no_rompe(tmp_path): + assert load_dotenv(tmp_path / "no-existe") == [] + + +def test_no_devuelve_valores(tmp_path): + """Devuelve solo nombres: estos archivos tienen secretos y no deben + terminar en un log.""" + definidas = load_dotenv(_env(tmp_path, "PRUEBA_A=secreto-que-no-debe-salir\n")) + assert definidas == ["PRUEBA_A"] + assert "secreto-que-no-debe-salir" not in str(definidas)