Spaces:
Runtime error
Runtime error
Commit ·
7e1ccef
1
Parent(s): 6de0428
feat(web): load API keys from git-ignored .env at startup (fixes ollama:*-cloud key error)
Browse files- proteus/env_loader.py +74 -0
- proteus/web/local/__main__.py +7 -0
- tests/test_env_loader.py +35 -0
proteus/env_loader.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal, dependency-free ``.env`` loader.
|
| 2 |
+
|
| 3 |
+
The project keeps provider API keys (``OLLAMA_API_KEY``, ``OPENAI_API_KEY``,
|
| 4 |
+
``ANTHROPIC_API_KEY``, …) in a git-ignored ``.env`` at the repo root. Nothing
|
| 5 |
+
loads it automatically, so a freshly launched web/CLI process doesn't see those
|
| 6 |
+
keys unless the user exported them by hand — which is why ``ollama:…-cloud``
|
| 7 |
+
fails with "API key must be provided".
|
| 8 |
+
|
| 9 |
+
``load_dotenv()`` finds the nearest ``.env`` walking up from the current working
|
| 10 |
+
directory and copies its ``KEY=VALUE`` lines into ``os.environ``. Real
|
| 11 |
+
environment variables win by default (``override=False``), so an explicitly
|
| 12 |
+
exported key is never clobbered. No third-party dependency (keeps the offline
|
| 13 |
+
test suite dependency-free).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _parse(text: str) -> dict[str, str]:
|
| 23 |
+
out: dict[str, str] = {}
|
| 24 |
+
for raw in text.splitlines():
|
| 25 |
+
line = raw.strip()
|
| 26 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 27 |
+
continue
|
| 28 |
+
if line.lower().startswith("export "):
|
| 29 |
+
line = line[len("export "):].lstrip()
|
| 30 |
+
key, _, val = line.partition("=")
|
| 31 |
+
key = key.strip()
|
| 32 |
+
val = val.strip()
|
| 33 |
+
if val[:1] in {'"', "'"} and val[-1:] == val[:1]:
|
| 34 |
+
val = val[1:-1] # quoted: keep verbatim (may contain '#')
|
| 35 |
+
else:
|
| 36 |
+
cut = val.find(" #") # unquoted: drop a whitespace-led inline comment
|
| 37 |
+
if cut != -1:
|
| 38 |
+
val = val[:cut].rstrip()
|
| 39 |
+
if key:
|
| 40 |
+
out[key] = val
|
| 41 |
+
return out
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def find_dotenv(start: str | os.PathLike[str] | None = None) -> Path | None:
|
| 45 |
+
"""Return the nearest ``.env`` walking up from *start* (default CWD)."""
|
| 46 |
+
base = Path(start) if start is not None else Path.cwd()
|
| 47 |
+
base = base.resolve()
|
| 48 |
+
for d in (base, *base.parents):
|
| 49 |
+
candidate = d / ".env"
|
| 50 |
+
if candidate.is_file():
|
| 51 |
+
return candidate
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_dotenv(
|
| 56 |
+
start: str | os.PathLike[str] | None = None, *, override: bool = False
|
| 57 |
+
) -> str | None:
|
| 58 |
+
"""Load the nearest ``.env`` into ``os.environ``.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
start: Directory to begin the upward search (default: CWD).
|
| 62 |
+
override: If True, ``.env`` values replace existing env vars. Default
|
| 63 |
+
False — a key already in the environment is left untouched.
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
The path of the ``.env`` that was loaded, or ``None`` if none was found.
|
| 67 |
+
"""
|
| 68 |
+
path = find_dotenv(start)
|
| 69 |
+
if path is None:
|
| 70 |
+
return None
|
| 71 |
+
for key, val in _parse(path.read_text(encoding="utf-8")).items():
|
| 72 |
+
if override or key not in os.environ:
|
| 73 |
+
os.environ[key] = val
|
| 74 |
+
return str(path)
|
proteus/web/local/__main__.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
| 4 |
import argparse
|
| 5 |
|
| 6 |
import proteus.game.scenarios # noqa: F401 (registers scenarios before any request)
|
|
|
|
| 7 |
from proteus.web.local.server import make_server
|
| 8 |
|
| 9 |
|
|
@@ -13,6 +14,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 13 |
parser.add_argument("--port", type=int, default=8000)
|
| 14 |
args = parser.parse_args(argv)
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
server = make_server(args.host, args.port)
|
| 17 |
host, port = server.server_address[0], server.server_address[1]
|
| 18 |
print(f"AgentnessBench — open http://{host}:{port}/ (Ctrl-C to stop)")
|
|
|
|
| 4 |
import argparse
|
| 5 |
|
| 6 |
import proteus.game.scenarios # noqa: F401 (registers scenarios before any request)
|
| 7 |
+
from proteus.env_loader import load_dotenv
|
| 8 |
from proteus.web.local.server import make_server
|
| 9 |
|
| 10 |
|
|
|
|
| 14 |
parser.add_argument("--port", type=int, default=8000)
|
| 15 |
args = parser.parse_args(argv)
|
| 16 |
|
| 17 |
+
# Load provider API keys from the repo's git-ignored .env so cloud models
|
| 18 |
+
# (ollama:*-cloud, openai, anthropic, …) work without a manual `export`.
|
| 19 |
+
loaded = load_dotenv()
|
| 20 |
+
if loaded:
|
| 21 |
+
print(f"loaded API keys from {loaded}")
|
| 22 |
+
|
| 23 |
server = make_server(args.host, args.port)
|
| 24 |
host, port = server.server_address[0], server.server_address[1]
|
| 25 |
print(f"AgentnessBench — open http://{host}:{port}/ (Ctrl-C to stop)")
|
tests/test_env_loader.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""env_loader: load the nearest .env without clobbering real env vars."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
from proteus.env_loader import find_dotenv, load_dotenv
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_loads_keys_without_overriding_existing(tmp_path, monkeypatch):
|
| 8 |
+
(tmp_path / ".env").write_text(
|
| 9 |
+
"# provider keys\n"
|
| 10 |
+
"OLLAMA_API_KEY=from_dotenv\n"
|
| 11 |
+
'OPENAI_API_KEY="quoted_value"\n'
|
| 12 |
+
"export ANTHROPIC_API_KEY=exported_style # inline note\n"
|
| 13 |
+
"ALREADY_SET=should_not_win\n"
|
| 14 |
+
)
|
| 15 |
+
monkeypatch.setenv("ALREADY_SET", "real_env") # real env must win
|
| 16 |
+
for k in ("OLLAMA_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
|
| 17 |
+
monkeypatch.delenv(k, raising=False) # ensure .env values can land
|
| 18 |
+
|
| 19 |
+
loaded = load_dotenv(start=tmp_path)
|
| 20 |
+
assert loaded == str(tmp_path / ".env")
|
| 21 |
+
assert os.environ["OLLAMA_API_KEY"] == "from_dotenv"
|
| 22 |
+
assert os.environ["OPENAI_API_KEY"] == "quoted_value" # quotes stripped
|
| 23 |
+
assert os.environ["ANTHROPIC_API_KEY"] == "exported_style" # export + inline comment handled
|
| 24 |
+
assert os.environ["ALREADY_SET"] == "real_env" # not overridden
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_finds_dotenv_walking_up(tmp_path):
|
| 28 |
+
(tmp_path / ".env").write_text("K=v\n")
|
| 29 |
+
deep = tmp_path / "a" / "b" / "c"
|
| 30 |
+
deep.mkdir(parents=True)
|
| 31 |
+
assert find_dotenv(start=deep) == tmp_path / ".env" # walks up to the root .env
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_returns_none_when_absent(tmp_path):
|
| 35 |
+
assert load_dotenv(start=tmp_path) is None
|