Skip to content

Commit 7ad7afe

Browse files
authored
feat: add .env fallback and refine dotenv loading (#50)
2 parents 5900306 + 04af8ae commit 7ad7afe

4 files changed

Lines changed: 222 additions & 5 deletions

File tree

agentkit/platform/configuration.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ def get_service_credentials(self, service_key: str) -> Credentials:
164164
3. Global Env Vars
165165
4. Global Config File
166166
5. VeFaaS IAM
167+
6. .env file in current working directory (fallback)
167168
"""
168169
# 1. Explicit
169170
if self._ak and self._sk:
@@ -189,11 +190,76 @@ def get_service_credentials(self, service_key: str) -> Credentials:
189190
if creds := self._get_credential_from_vefaas_iam():
190191
return creds
191192

193+
# 6. .env file fallback (Current Working Directory)
194+
if creds := self._get_dotenv_credentials(service_key):
195+
return creds
196+
192197
raise ValueError(
193-
f"Volcengine credentials not found (Service: {service_key}). Please set environment variables VOLCENGINE_ACCESS_KEY and "
194-
"VOLCENGINE_SECRET_KEY, or configure in global config file ~/.agentkit/config.yaml."
198+
"\n".join(
199+
[
200+
f"Volcengine credentials not found (Service: {service_key}).",
201+
"Recommended (global, set once):",
202+
" agentkit config --global --set volcengine.access_key=YOUR_ACCESS_KEY",
203+
" agentkit config --global --set volcengine.secret_key=YOUR_SECRET_KEY",
204+
"Alternative (per-shell):",
205+
" export VOLCENGINE_ACCESS_KEY=YOUR_ACCESS_KEY",
206+
" export VOLCENGINE_SECRET_KEY=YOUR_SECRET_KEY",
207+
]
208+
)
195209
)
196210

211+
def _get_dotenv_credentials(self, service_key: str) -> Optional[Credentials]:
212+
"""Attempt to read credentials from a local .env file.
213+
214+
This is a last-resort fallback for CLI users who commonly expect `.env` in the
215+
current working directory to provide environment variables.
216+
217+
Notes:
218+
- Reads only `Path.cwd() / '.env'`.
219+
- Does NOT mutate the current process environment.
220+
"""
221+
222+
try:
223+
from dotenv import dotenv_values
224+
except Exception:
225+
return None
226+
227+
env_file_path = Path.cwd() / ".env"
228+
229+
try:
230+
values = dotenv_values(env_file_path)
231+
except Exception:
232+
return None
233+
234+
if not isinstance(values, dict):
235+
return None
236+
237+
def _get(key: str) -> str:
238+
v = values.get(key)
239+
return str(v) if v is not None else ""
240+
241+
svc_upper = service_key.upper()
242+
243+
# Service-specific keys (align with environment variable behavior)
244+
ak = _get(f"VOLCENGINE_{svc_upper}_ACCESS_KEY")
245+
sk = _get(f"VOLCENGINE_{svc_upper}_SECRET_KEY")
246+
if not ak or not sk:
247+
# Legacy support
248+
ak = ak or _get(f"VOLC_{svc_upper}_ACCESSKEY")
249+
sk = sk or _get(f"VOLC_{svc_upper}_SECRETKEY")
250+
251+
if ak and sk:
252+
return Credentials(access_key=ak, secret_key=sk)
253+
254+
# Global keys
255+
ak = _get("VOLCENGINE_ACCESS_KEY") or _get("VOLC_ACCESSKEY")
256+
sk = _get("VOLCENGINE_SECRET_KEY") or _get("VOLC_SECRETKEY")
257+
258+
if ak and sk:
259+
return Credentials(access_key=ak, secret_key=sk)
260+
261+
return None
262+
197263
def _get_service_env_credentials(self, service_key: str) -> Optional[Credentials]:
198264
svc_upper = service_key.upper()
199265
ak = os.getenv(f"VOLCENGINE_{svc_upper}_ACCESS_KEY")

agentkit/toolkit/config/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from typing import Dict, Any, Optional
1919
import logging
2020

21-
from dotenv import load_dotenv, dotenv_values
21+
from dotenv import dotenv_values
2222
from yaml import safe_load, YAMLError
2323

2424
from .constants import AUTO_CREATE_VE
@@ -48,8 +48,7 @@ def load_dotenv_file(project_dir: Path) -> Dict[str, str]:
4848
if not env_file_path.exists():
4949
return {}
5050

51-
# Load .env into environment temporarily to get the values
52-
load_dotenv(env_file_path)
51+
# Parse values without mutating the current process environment.
5352
env_values = dotenv_values(env_file_path)
5453
return {k: str(v) for k, v in env_values.items() if v is not None}
5554

tests/platform/test_configuration_creds.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import pytest
1616
import os
17+
from pathlib import Path
1718
from agentkit.platform.configuration import VolcConfiguration
1819

1920

@@ -91,6 +92,101 @@ def test_creds_vefaas_fallback(
9192
assert creds.secret_key == "vefaas_sk"
9293
assert creds.session_token == "vefaas_token"
9394

95+
def test_creds_dotenv_fallback_from_cwd(
96+
self, clean_env, mock_global_config, monkeypatch, tmp_path, mocker
97+
):
98+
"""Test fallback to .env in current working directory when other sources are missing."""
99+
# Ensure VeFaaS IAM check fails
100+
mocker.patch("pathlib.Path.exists", return_value=False)
101+
102+
monkeypatch.chdir(tmp_path)
103+
(tmp_path / ".env").write_text(
104+
"VOLCENGINE_ACCESS_KEY=AK_FROM_DOTENV\nVOLCENGINE_SECRET_KEY=SK_FROM_DOTENV\n",
105+
encoding="utf-8",
106+
)
107+
108+
config = VolcConfiguration()
109+
creds = config.get_service_credentials("agentkit")
110+
111+
assert creds.access_key == "AK_FROM_DOTENV"
112+
assert creds.secret_key == "SK_FROM_DOTENV"
113+
114+
def test_creds_dotenv_does_not_override_global_env(
115+
self, clean_env, mock_global_config, monkeypatch, tmp_path
116+
):
117+
"""Test that .env fallback never overrides real process environment variables."""
118+
os.environ["VOLCENGINE_ACCESS_KEY"] = "AK_FROM_ENV"
119+
os.environ["VOLCENGINE_SECRET_KEY"] = "SK_FROM_ENV"
120+
121+
monkeypatch.chdir(tmp_path)
122+
(tmp_path / ".env").write_text(
123+
"VOLCENGINE_ACCESS_KEY=AK_FROM_DOTENV\nVOLCENGINE_SECRET_KEY=SK_FROM_DOTENV\n",
124+
encoding="utf-8",
125+
)
126+
127+
config = VolcConfiguration()
128+
creds = config.get_service_credentials("agentkit")
129+
130+
assert creds.access_key == "AK_FROM_ENV"
131+
assert creds.secret_key == "SK_FROM_ENV"
132+
133+
def test_creds_dotenv_does_not_override_global_config(
134+
self, clean_env, mock_global_config, monkeypatch, tmp_path, mocker
135+
):
136+
"""Test that .env fallback never overrides ~/.agentkit/config.yaml credentials."""
137+
# Ensure VeFaaS IAM check fails
138+
mocker.patch("pathlib.Path.exists", return_value=False)
139+
140+
mock_global_config.update(
141+
{"volcengine": {"access_key": "AK_FROM_CFG", "secret_key": "SK_FROM_CFG"}}
142+
)
143+
144+
monkeypatch.chdir(tmp_path)
145+
(tmp_path / ".env").write_text(
146+
"VOLCENGINE_ACCESS_KEY=AK_FROM_DOTENV\nVOLCENGINE_SECRET_KEY=SK_FROM_DOTENV\n",
147+
encoding="utf-8",
148+
)
149+
150+
config = VolcConfiguration()
151+
creds = config.get_service_credentials("agentkit")
152+
153+
assert creds.access_key == "AK_FROM_CFG"
154+
assert creds.secret_key == "SK_FROM_CFG"
155+
156+
def test_creds_dotenv_partial_is_ignored(
157+
self, clean_env, mock_global_config, monkeypatch, tmp_path, mocker
158+
):
159+
"""Test that partial .env credentials are ignored and lookup continues."""
160+
# Ensure VeFaaS IAM check fails
161+
mocker.patch("pathlib.Path.exists", return_value=False)
162+
163+
monkeypatch.chdir(tmp_path)
164+
(tmp_path / ".env").write_text(
165+
"VOLCENGINE_ACCESS_KEY=AK_ONLY\n",
166+
encoding="utf-8",
167+
)
168+
169+
config = VolcConfiguration()
170+
with pytest.raises(ValueError, match="Volcengine credentials not found"):
171+
config.get_service_credentials("agentkit")
172+
173+
def test_creds_vefaas_takes_priority_over_dotenv(
174+
self, clean_env, mock_global_config, mock_vefaas_file, monkeypatch, tmp_path
175+
):
176+
"""Test that VeFaaS IAM credentials take priority over .env fallback."""
177+
monkeypatch.chdir(tmp_path)
178+
(tmp_path / ".env").write_text(
179+
"VOLCENGINE_ACCESS_KEY=AK_FROM_DOTENV\nVOLCENGINE_SECRET_KEY=SK_FROM_DOTENV\n",
180+
encoding="utf-8",
181+
)
182+
183+
config = VolcConfiguration()
184+
creds = config.get_service_credentials("agentkit")
185+
186+
assert creds.access_key == "vefaas_ak"
187+
assert creds.secret_key == "vefaas_sk"
188+
assert creds.session_token == "vefaas_token"
189+
94190
def test_creds_missing_error(self, clean_env, mock_global_config, mocker):
95191
"""Test error raised when no credentials found."""
96192
mocker.patch("pathlib.Path.exists", return_value=False)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import os
2+
3+
from agentkit.toolkit.config import CommonConfig
4+
from agentkit.toolkit.config.utils import load_dotenv_file, merge_runtime_envs
5+
6+
7+
def test_load_dotenv_file_returns_values(tmp_path, monkeypatch):
8+
env_file = tmp_path / ".env"
9+
env_file.write_text("FOO=bar\nHELLO=world\n", encoding="utf-8")
10+
11+
monkeypatch.delenv("FOO", raising=False)
12+
monkeypatch.delenv("HELLO", raising=False)
13+
14+
values = load_dotenv_file(tmp_path)
15+
16+
assert values["FOO"] == "bar"
17+
assert values["HELLO"] == "world"
18+
19+
20+
def test_load_dotenv_file_does_not_mutate_process_environment(tmp_path, monkeypatch):
21+
env_file = tmp_path / ".env"
22+
env_file.write_text(
23+
"FOO=bar\nVOLCENGINE_ACCESS_KEY=ak_from_dotenv\n",
24+
encoding="utf-8",
25+
)
26+
27+
monkeypatch.delenv("FOO", raising=False)
28+
monkeypatch.delenv("VOLCENGINE_ACCESS_KEY", raising=False)
29+
30+
values = load_dotenv_file(tmp_path)
31+
assert values["FOO"] == "bar"
32+
assert values["VOLCENGINE_ACCESS_KEY"] == "ak_from_dotenv"
33+
34+
assert "FOO" not in os.environ
35+
assert "VOLCENGINE_ACCESS_KEY" not in os.environ
36+
37+
38+
def test_merge_runtime_envs_precedence_includes_dotenv(tmp_path):
39+
(tmp_path / "config.yaml").write_text(
40+
"model:\n api_key: from_config\n",
41+
encoding="utf-8",
42+
)
43+
(tmp_path / ".env").write_text(
44+
"A=dotenv\nB=dotenv\nMODEL_API_KEY=from_env\n",
45+
encoding="utf-8",
46+
)
47+
48+
common_config = CommonConfig(runtime_envs={"A": "common", "B": "common"})
49+
strategy_config = {"runtime_envs": {"B": "strategy", "C": "strategy"}}
50+
51+
merged = merge_runtime_envs(common_config, strategy_config, project_dir=tmp_path)
52+
53+
assert merged["A"] == "common"
54+
assert merged["B"] == "strategy"
55+
assert merged["C"] == "strategy"
56+
assert merged["MODEL_API_KEY"] == "from_env"

0 commit comments

Comments
 (0)