Compare commits
2 Commits
03dadc93da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fac220338 | |||
| 59b7c3acb6 |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@
|
||||
# Secretos
|
||||
.env
|
||||
|
||||
# Config personal de Claude Code. Hasta ahora solo la ignoraba un gitignore
|
||||
# global de esta máquina, que no existe en arkax ni en un clon nuevo: ahí el
|
||||
# archivo se habría subido sin que nadie lo notara. La regla tiene que vivir en
|
||||
# el repositorio, no en la configuración de una computadora.
|
||||
.claude/settings.local.json
|
||||
|
||||
# Config del IDE, específica de cada máquina
|
||||
.vscode/
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
@@ -374,7 +375,18 @@ def build_backend(config) -> SearchBackend:
|
||||
|
||||
Con un solo backend devuelve ese backend pelado, para no envolver en una
|
||||
cadena algo que no la necesita.
|
||||
|
||||
Los respaldos sin credencial ya vienen filtrados de la config, pero se avisa
|
||||
acá: quedarse sin red de seguridad no puede pasar en silencio, y este es el
|
||||
punto por el que pasa cualquier entrypoint que use búsqueda.
|
||||
"""
|
||||
for nombre, variable in config.respaldos_omitidos:
|
||||
warnings.warn(
|
||||
f"búsqueda: el respaldo '{nombre}' queda deshabilitado porque falta "
|
||||
f"{variable}. Si el primario '{config.backend}' falla, no hay red.",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
cadena = [_construir_uno(nombre, config) for nombre in config.cadena]
|
||||
return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena)
|
||||
|
||||
|
||||
+51
-21
@@ -250,32 +250,62 @@ class SearchConfig(_Base):
|
||||
searxng_url: str | None = None
|
||||
brave_api_key: str | None = None
|
||||
|
||||
@property
|
||||
def cadena(self) -> list[str]:
|
||||
"""El primario seguido de los respaldos, sin repetidos."""
|
||||
orden = [self.backend, *self.fallbacks]
|
||||
vistos: list[str] = []
|
||||
for nombre in orden:
|
||||
if nombre not in vistos:
|
||||
vistos.append(nombre)
|
||||
return vistos
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_backends(self) -> SearchConfig:
|
||||
# Un backend sin credencial se descubriría recién en la primera consulta
|
||||
# real, que es justo cuando el primario ya falló y el respaldo tiene que
|
||||
# funcionar. Se valida al arrancar.
|
||||
requisitos = {
|
||||
# Qué credencial necesita cada backend, y de qué variable de entorno sale.
|
||||
_CREDENCIALES = {
|
||||
"searxng": ("searxng_url", "ENLACE_SEARXNG_URL"),
|
||||
"brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"),
|
||||
}
|
||||
for nombre in self.cadena:
|
||||
requisito = requisitos.get(nombre)
|
||||
|
||||
def _falta_credencial(self, nombre: str) -> str | None:
|
||||
requisito = self._CREDENCIALES.get(nombre)
|
||||
if requisito and not getattr(self, requisito[0]):
|
||||
rol = "backend" if nombre == self.backend else "fallback"
|
||||
return requisito[1]
|
||||
return None
|
||||
|
||||
@property
|
||||
def cadena(self) -> list[str]:
|
||||
"""El primario seguido de los respaldos utilizables, sin repetidos.
|
||||
|
||||
Un respaldo sin su credencial se omite en vez de romper la carga. La
|
||||
razón es concreta: la config vive en el repositorio y las credenciales
|
||||
no, así que exigirlas para poder *leerla* dejaba el proyecto sin poder
|
||||
clonarse — el CI y cualquier máquina nueva fallaban antes de empezar.
|
||||
|
||||
Omitir no es esconder: `respaldos_omitidos` los expone y `build_backend`
|
||||
avisa. El primario es otra cosa y sigue siendo error fatal (ver abajo):
|
||||
sin él no hay búsqueda posible.
|
||||
"""
|
||||
vistos: list[str] = []
|
||||
for nombre in [self.backend, *self.fallbacks]:
|
||||
if nombre in vistos:
|
||||
continue
|
||||
if nombre != self.backend and self._falta_credencial(nombre):
|
||||
continue
|
||||
vistos.append(nombre)
|
||||
return vistos
|
||||
|
||||
@property
|
||||
def respaldos_omitidos(self) -> list[tuple[str, str]]:
|
||||
"""Respaldos descartados por falta de credencial, con qué variable falta."""
|
||||
omitidos = []
|
||||
for nombre in self.fallbacks:
|
||||
if nombre == self.backend:
|
||||
continue
|
||||
variable = self._falta_credencial(nombre)
|
||||
if variable:
|
||||
omitidos.append((nombre, variable))
|
||||
return omitidos
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_backend_primario(self) -> SearchConfig:
|
||||
# El primario sí es fatal: sin él no queda ninguna búsqueda en pie, y
|
||||
# descubrirlo en la primera consulta real es tarde.
|
||||
variable = self._falta_credencial(self.backend)
|
||||
if variable:
|
||||
campo = self._CREDENCIALES[self.backend][0]
|
||||
raise ValueError(
|
||||
f"search: el {rol} '{nombre}' exige {requisito[0]} "
|
||||
f"(definí {requisito[1]} en .env)."
|
||||
f"search: el backend primario '{self.backend}' exige {campo} "
|
||||
f"(definí {variable} en .env)."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
+41
-10
@@ -193,7 +193,7 @@ def test_el_primario_no_necesita_credenciales():
|
||||
assert isinstance(primero, DuckDuckGoBackend)
|
||||
|
||||
|
||||
def test_searxng_exige_url():
|
||||
def test_searxng_como_primario_exige_url():
|
||||
with pytest.raises(ValueError, match="searxng_url"):
|
||||
SearchConfig(backend="searxng")
|
||||
|
||||
@@ -307,22 +307,53 @@ def test_el_fallback_repetido_no_se_duplica():
|
||||
assert cfg.cadena == ["duckduckgo", "brave"]
|
||||
|
||||
|
||||
def test_un_fallback_sin_credencial_falla_al_arrancar():
|
||||
"""No en la primera consulta real — que es justo cuando el primario ya
|
||||
falló y el respaldo tiene que funcionar."""
|
||||
with pytest.raises(ValueError, match="fallback 'brave'"):
|
||||
SearchConfig(backend="duckduckgo", fallbacks=["brave"])
|
||||
def test_un_respaldo_sin_credencial_se_omite_en_vez_de_romper():
|
||||
"""La config vive en el repositorio y las credenciales no.
|
||||
|
||||
Exigir la clave para poder *leer* la config dejaba el proyecto sin poder
|
||||
clonarse: el CI y cualquier máquina nueva fallaban antes de empezar. El
|
||||
respaldo se cae de la cadena; el primario sigue funcionando.
|
||||
"""
|
||||
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
|
||||
assert cfg.cadena == ["duckduckgo"]
|
||||
|
||||
|
||||
def test_brave_como_primario_tambien_exige_credencial():
|
||||
with pytest.raises(ValueError, match="backend 'brave'"):
|
||||
def test_el_respaldo_omitido_queda_a_la_vista():
|
||||
"""Omitir no es esconder: quedarse sin red de seguridad tiene que verse."""
|
||||
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
|
||||
assert cfg.respaldos_omitidos == [("brave", "ENLACE_BRAVE_API_KEY")]
|
||||
|
||||
|
||||
def test_construir_la_cadena_avisa_del_respaldo_faltante():
|
||||
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"])
|
||||
with pytest.warns(RuntimeWarning, match="ENLACE_BRAVE_API_KEY"):
|
||||
build_backend(cfg)
|
||||
|
||||
|
||||
def test_con_credencial_el_respaldo_si_entra():
|
||||
cfg = SearchConfig(backend="duckduckgo", fallbacks=["brave"], brave_api_key="x")
|
||||
assert cfg.cadena == ["duckduckgo", "brave"]
|
||||
assert cfg.respaldos_omitidos == []
|
||||
|
||||
|
||||
def test_el_primario_sin_credencial_si_es_fatal():
|
||||
"""Sin primario no queda ninguna búsqueda en pie: eso no se degrada."""
|
||||
with pytest.raises(ValueError, match="primario 'brave'"):
|
||||
SearchConfig(backend="brave")
|
||||
|
||||
|
||||
def test_la_config_del_repo_declara_brave_de_respaldo():
|
||||
def test_la_config_del_repo_carga_sin_credenciales(monkeypatch):
|
||||
"""Un clon limpio, sin .env, tiene que poder leer la config del repositorio.
|
||||
|
||||
Es exactamente lo que rompió en el CI: la validación exigía la clave de
|
||||
Brave para poder cargar el archivo.
|
||||
"""
|
||||
monkeypatch.delenv("ENLACE_BRAVE_API_KEY", raising=False)
|
||||
monkeypatch.setattr("enlace.config.load.load_dotenv", lambda *a, **k: [])
|
||||
cfg = load_agent_config()
|
||||
assert cfg.search.backend == "duckduckgo"
|
||||
assert "brave" in cfg.search.fallbacks
|
||||
assert "brave" in cfg.search.fallbacks # la intención queda declarada
|
||||
assert cfg.search.cadena == ["duckduckgo"] # pero no se usa sin credencial
|
||||
|
||||
|
||||
# --- parser de Brave -------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user