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:
2026-07-27 23:32:42 -03:00
parent d0a05cdd97
commit b88264f41b
4 changed files with 148 additions and 1 deletions
+2
View File
@@ -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",
]
+51
View File
@@ -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
+13 -1
View File
@@ -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]: