Cargar .env en 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 nada cargaba el archivo. Una clave correctamente escrita en .env simplemente no existía para el proceso, y el síntoma —error de autenticación con el archivo bien configurado— es de los más molestos de diagnosticar. load_dotenv() 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. Detalles que importan: - 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. - Ignora claves con valor vacío: una variable vacía autentica peor que una ausente, porque parece presente. - Acepta la forma `export FOO=bar`, porque el mismo archivo lo consume `source .env` desde remote.sh. - Devuelve nombres, nunca valores: estos archivos tienen secretos y no deben terminar en un log. Además, ClienteAnthropic ahora falla con un mensaje que dice qué hacer cuando no hay credencial, en vez de propagar el error del SDK. 10 tests nuevos (102 en total). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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 (
|
from enlace.config.load import (
|
||||||
load_agent_config,
|
load_agent_config,
|
||||||
load_config,
|
load_config,
|
||||||
@@ -36,6 +37,7 @@ __all__ = [
|
|||||||
"TrainConfig",
|
"TrainConfig",
|
||||||
"load_agent_config",
|
"load_agent_config",
|
||||||
"load_config",
|
"load_config",
|
||||||
|
"load_dotenv",
|
||||||
"load_config_from_argv",
|
"load_config_from_argv",
|
||||||
"load_distill_config",
|
"load_distill_config",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -37,6 +37,7 @@ from dataclasses import asdict, dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable, Iterator, Protocol
|
from typing import Any, Iterable, Iterator, Protocol
|
||||||
|
|
||||||
|
from enlace.config.env import load_dotenv
|
||||||
from enlace.config.schema import DistillConfig
|
from enlace.config.schema import DistillConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -105,8 +106,19 @@ class ClienteAnthropic:
|
|||||||
raise DistillError(
|
raise DistillError(
|
||||||
"falta el SDK: instalá el extra con `pip install -e '.[distill]'`"
|
"falta el SDK: instalá el extra con `pip install -e '.[distill]'`"
|
||||||
) from None
|
) 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._anthropic = anthropic
|
||||||
|
try:
|
||||||
self._client = anthropic.Anthropic()
|
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
|
self.config = config
|
||||||
|
|
||||||
def construir_params(self, peticion: Peticion) -> dict[str, Any]:
|
def construir_params(self, peticion: Peticion) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user