Compare commits
2 Commits
03dadc93da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fac220338 | |||
| 59b7c3acb6 |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
# Secretos
|
# Secretos
|
||||||
.env
|
.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
|
# Config del IDE, específica de cada máquina
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import warnings
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
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
|
Con un solo backend devuelve ese backend pelado, para no envolver en una
|
||||||
cadena algo que no la necesita.
|
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]
|
cadena = [_construir_uno(nombre, config) for nombre in config.cadena]
|
||||||
return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena)
|
return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena)
|
||||||
|
|
||||||
|
|||||||
+51
-21
@@ -250,33 +250,63 @@ class SearchConfig(_Base):
|
|||||||
searxng_url: str | None = None
|
searxng_url: str | None = None
|
||||||
brave_api_key: str | None = None
|
brave_api_key: str | None = None
|
||||||
|
|
||||||
|
# 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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _falta_credencial(self, nombre: str) -> str | None:
|
||||||
|
requisito = self._CREDENCIALES.get(nombre)
|
||||||
|
if requisito and not getattr(self, requisito[0]):
|
||||||
|
return requisito[1]
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cadena(self) -> list[str]:
|
def cadena(self) -> list[str]:
|
||||||
"""El primario seguido de los respaldos, sin repetidos."""
|
"""El primario seguido de los respaldos utilizables, sin repetidos.
|
||||||
orden = [self.backend, *self.fallbacks]
|
|
||||||
|
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] = []
|
vistos: list[str] = []
|
||||||
for nombre in orden:
|
for nombre in [self.backend, *self.fallbacks]:
|
||||||
if nombre not in vistos:
|
if nombre in vistos:
|
||||||
vistos.append(nombre)
|
continue
|
||||||
|
if nombre != self.backend and self._falta_credencial(nombre):
|
||||||
|
continue
|
||||||
|
vistos.append(nombre)
|
||||||
return vistos
|
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")
|
@model_validator(mode="after")
|
||||||
def _check_backends(self) -> SearchConfig:
|
def _check_backend_primario(self) -> SearchConfig:
|
||||||
# Un backend sin credencial se descubriría recién en la primera consulta
|
# El primario sí es fatal: sin él no queda ninguna búsqueda en pie, y
|
||||||
# real, que es justo cuando el primario ya falló y el respaldo tiene que
|
# descubrirlo en la primera consulta real es tarde.
|
||||||
# funcionar. Se valida al arrancar.
|
variable = self._falta_credencial(self.backend)
|
||||||
requisitos = {
|
if variable:
|
||||||
"searxng": ("searxng_url", "ENLACE_SEARXNG_URL"),
|
campo = self._CREDENCIALES[self.backend][0]
|
||||||
"brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"),
|
raise ValueError(
|
||||||
}
|
f"search: el backend primario '{self.backend}' exige {campo} "
|
||||||
for nombre in self.cadena:
|
f"(definí {variable} en .env)."
|
||||||
requisito = requisitos.get(nombre)
|
)
|
||||||
if requisito and not getattr(self, requisito[0]):
|
|
||||||
rol = "backend" if nombre == self.backend else "fallback"
|
|
||||||
raise ValueError(
|
|
||||||
f"search: el {rol} '{nombre}' exige {requisito[0]} "
|
|
||||||
f"(definí {requisito[1]} en .env)."
|
|
||||||
)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+41
-10
@@ -193,7 +193,7 @@ def test_el_primario_no_necesita_credenciales():
|
|||||||
assert isinstance(primero, DuckDuckGoBackend)
|
assert isinstance(primero, DuckDuckGoBackend)
|
||||||
|
|
||||||
|
|
||||||
def test_searxng_exige_url():
|
def test_searxng_como_primario_exige_url():
|
||||||
with pytest.raises(ValueError, match="searxng_url"):
|
with pytest.raises(ValueError, match="searxng_url"):
|
||||||
SearchConfig(backend="searxng")
|
SearchConfig(backend="searxng")
|
||||||
|
|
||||||
@@ -307,22 +307,53 @@ def test_el_fallback_repetido_no_se_duplica():
|
|||||||
assert cfg.cadena == ["duckduckgo", "brave"]
|
assert cfg.cadena == ["duckduckgo", "brave"]
|
||||||
|
|
||||||
|
|
||||||
def test_un_fallback_sin_credencial_falla_al_arrancar():
|
def test_un_respaldo_sin_credencial_se_omite_en_vez_de_romper():
|
||||||
"""No en la primera consulta real — que es justo cuando el primario ya
|
"""La config vive en el repositorio y las credenciales no.
|
||||||
falló y el respaldo tiene que funcionar."""
|
|
||||||
with pytest.raises(ValueError, match="fallback 'brave'"):
|
Exigir la clave para poder *leer* la config dejaba el proyecto sin poder
|
||||||
SearchConfig(backend="duckduckgo", fallbacks=["brave"])
|
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():
|
def test_el_respaldo_omitido_queda_a_la_vista():
|
||||||
with pytest.raises(ValueError, match="backend 'brave'"):
|
"""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")
|
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()
|
cfg = load_agent_config()
|
||||||
assert cfg.search.backend == "duckduckgo"
|
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 -------------------------------------------------------
|
# --- parser de Brave -------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user