[配置]:完成 .env 文件读取配置,包含自动加载、CLI 参数、测试覆盖和中文说明
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
密钥不得写入仓库。运行时通过环境变量配置:
|
密钥不得写入仓库。运行时默认读取项目根目录的 `.env` 文件:
|
||||||
|
|
||||||
- `OWNER_LLM_BASE_URL`
|
- `OWNER_LLM_BASE_URL`
|
||||||
- `OWNER_LLM_API_KEY`
|
- `OWNER_LLM_API_KEY`
|
||||||
@@ -25,6 +25,8 @@
|
|||||||
默认配置面向当前 NewAPI:
|
默认配置面向当前 NewAPI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# 然后编辑 .env,填入 OWNER_LLM_API_KEY
|
||||||
OWNER_LLM_BASE_URL=https://newapi.mkbk.shop
|
OWNER_LLM_BASE_URL=https://newapi.mkbk.shop
|
||||||
OWNER_LLM_MODEL=gpt-5.4-mini
|
OWNER_LLM_MODEL=gpt-5.4-mini
|
||||||
OWNER_LLM_API_STYLE=chat_completions
|
OWNER_LLM_API_STYLE=chat_completions
|
||||||
@@ -44,7 +46,6 @@ openspec validate --all --strict
|
|||||||
真实 NewAPI smoke 测试示例:
|
真实 NewAPI smoke 测试示例:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
OWNER_LLM_API_KEY=你的临时Key \
|
|
||||||
PYTHONPATH=src python3.11 -m owner_voice_pet llm-smoke --no-stream --message '用一句中文回复:小杰在线。'
|
PYTHONPATH=src python3.11 -m owner_voice_pet llm-smoke --no-stream --message '用一句中文回复:小杰在线。'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .wakeword import KeywordWakeWordProvider
|
|||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(prog="owner-voice-pet")
|
parser = argparse.ArgumentParser(prog="owner-voice-pet")
|
||||||
|
parser.add_argument("--env-file", default=".env", help="Path to .env config file")
|
||||||
parser.add_argument("--show-config", action="store_true", help="Print non-secret config summary")
|
parser.add_argument("--show-config", action="store_true", help="Print non-secret config summary")
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
subparsers.add_parser("acceptance", help="Run deterministic end-to-end pipeline acceptance")
|
subparsers.add_parser("acceptance", help="Run deterministic end-to-end pipeline acceptance")
|
||||||
@@ -32,7 +33,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.show_config:
|
if args.show_config:
|
||||||
config = AppConfig.from_env()
|
config = AppConfig.from_dotenv(args.env_file)
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
@@ -53,7 +54,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "validate-assets":
|
if args.command == "validate-assets":
|
||||||
infos = validate_pet_assets(AppConfig.from_env().asset_dir)
|
infos = validate_pet_assets(AppConfig.from_dotenv(args.env_file).asset_dir)
|
||||||
print(json.dumps({"assets": len(infos), "valid": True}, ensure_ascii=False, sort_keys=True))
|
print(json.dumps({"assets": len(infos), "valid": True}, ensure_ascii=False, sort_keys=True))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 0 if result["success"] else 1
|
return 0 if result["success"] else 1
|
||||||
|
|
||||||
if args.command == "llm-smoke":
|
if args.command == "llm-smoke":
|
||||||
config = AppConfig.from_env()
|
config = AppConfig.from_dotenv(args.env_file)
|
||||||
if args.no_stream:
|
if args.no_stream:
|
||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
wake_word=config.wake_word,
|
wake_word=config.wake_word,
|
||||||
@@ -102,7 +103,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def run_acceptance() -> dict[str, object]:
|
def run_acceptance() -> dict[str, object]:
|
||||||
validate_pet_assets(AppConfig.from_env().asset_dir)
|
validate_pet_assets(AppConfig.from_dotenv().asset_dir)
|
||||||
frames = [
|
frames = [
|
||||||
AudioFrame(b"\x80\x80", 16000, 1, 0, 0, {"duration_ms": 20, "wake_word": "小杰小杰", "wake_confidence": 0.99}),
|
AudioFrame(b"\x80\x80", 16000, 1, 0, 0, {"duration_ms": 20, "wake_word": "小杰小杰", "wake_confidence": 0.99}),
|
||||||
AudioFrame(b"\xff\xff", 16000, 1, 20, 1, {"duration_ms": 20, "speech": True, "transcript": "你是谁"}),
|
AudioFrame(b"\xff\xff", 16000, 1, 20, 1, {"duration_ms": 20, "speech": True, "transcript": "你是谁"}),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -25,9 +24,11 @@ class AppConfig:
|
|||||||
context_max_chars: int = 12000
|
context_max_chars: int = 12000
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, prefix: str = "OWNER_") -> "AppConfig":
|
def from_dotenv(cls, path: str | Path = ".env", prefix: str = "OWNER_") -> "AppConfig":
|
||||||
|
values = parse_dotenv(Path(path))
|
||||||
|
|
||||||
def get(name: str, default: str | None = None) -> str | None:
|
def get(name: str, default: str | None = None) -> str | None:
|
||||||
value = os.environ.get(f"{prefix}{name}")
|
value = values.get(f"{prefix}{name}")
|
||||||
return default if value is None or value == "" else value
|
return default if value is None or value == "" else value
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
@@ -47,6 +48,10 @@ class AppConfig:
|
|||||||
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
|
context_max_chars=int(get("CONTEXT_MAX_CHARS", "12000") or "12000"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, prefix: str = "OWNER_") -> "AppConfig":
|
||||||
|
return cls.from_dotenv(".env", prefix=prefix)
|
||||||
|
|
||||||
def require_llm_credentials(self) -> None:
|
def require_llm_credentials(self) -> None:
|
||||||
if not self.llm_api_key:
|
if not self.llm_api_key:
|
||||||
raise ProviderError(
|
raise ProviderError(
|
||||||
@@ -100,3 +105,32 @@ class AppConfig:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dotenv(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line.removeprefix("export ").strip()
|
||||||
|
if "=" not in line:
|
||||||
|
raise ValueError(f"invalid .env line {line_no}: missing '='")
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = _strip_dotenv_value(value.strip())
|
||||||
|
if not key:
|
||||||
|
raise ValueError(f"invalid .env line {line_no}: empty key")
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_dotenv_value(value: str) -> str:
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||||
|
return value[1:-1]
|
||||||
|
if " #" in value:
|
||||||
|
return value.split(" #", 1)[0].rstrip()
|
||||||
|
return value
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ class CliAcceptanceTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
self.assertTrue(data["valid"])
|
self.assertTrue(data["valid"])
|
||||||
|
|
||||||
|
def test_show_config_reads_env_file_without_printing_secret(self) -> None:
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = f"{tmp}/.env"
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("OWNER_LLM_API_KEY=secret-value\nOWNER_LLM_MODEL=file-model\n")
|
||||||
|
code, data = self.call("--env-file", path, "--show-config")
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertTrue(data["llm_api_key_present"])
|
||||||
|
self.assertEqual(data["llm_model"], "file-model")
|
||||||
|
self.assertNotIn("secret-value", str(data))
|
||||||
|
|
||||||
def test_security_check_command_has_no_leaks(self) -> None:
|
def test_security_check_command_has_no_leaks(self) -> None:
|
||||||
code, data = self.call("security-check")
|
code, data = self.call("security-check")
|
||||||
self.assertEqual(code, 0)
|
self.assertEqual(code, 0)
|
||||||
|
|||||||
+20
-11
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from owner_voice_pet.config import AppConfig
|
from owner_voice_pet.config import AppConfig
|
||||||
@@ -52,21 +52,30 @@ class ModelsConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("LLM_API_KEY_MISSING", str(error))
|
self.assertIn("LLM_API_KEY_MISSING", str(error))
|
||||||
self.assertIn("llm/openai-compatible", str(error))
|
self.assertIn("llm/openai-compatible", str(error))
|
||||||
|
|
||||||
def test_config_from_env_masks_secret_boundary(self) -> None:
|
def test_config_from_dotenv_uses_file_values(self) -> None:
|
||||||
old_env = dict(os.environ)
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
try:
|
path = f"{tmp}/.env"
|
||||||
os.environ["OWNER_LLM_BASE_URL"] = "https://newapi.mkbk.shop/"
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
os.environ["OWNER_LLM_API_KEY"] = "secret-value"
|
handle.write(
|
||||||
os.environ["OWNER_LLM_MODEL"] = "test-model"
|
"\n".join(
|
||||||
config = AppConfig.from_env()
|
[
|
||||||
|
"OWNER_LLM_BASE_URL=https://newapi.mkbk.shop/",
|
||||||
|
"OWNER_LLM_API_KEY=secret-value",
|
||||||
|
"OWNER_LLM_MODEL=test-model",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config = AppConfig.from_dotenv(path)
|
||||||
self.assertEqual(config.llm_base_url, "https://newapi.mkbk.shop")
|
self.assertEqual(config.llm_base_url, "https://newapi.mkbk.shop")
|
||||||
self.assertEqual(config.llm_api_key, "secret-value")
|
self.assertEqual(config.llm_api_key, "secret-value")
|
||||||
self.assertEqual(config.llm_model, "test-model")
|
self.assertEqual(config.llm_model, "test-model")
|
||||||
self.assertTrue(config.llm_stream)
|
self.assertTrue(config.llm_stream)
|
||||||
self.assertEqual(config.validate_basic(), [])
|
self.assertEqual(config.validate_basic(), [])
|
||||||
finally:
|
|
||||||
os.environ.clear()
|
def test_missing_dotenv_uses_non_secret_defaults(self) -> None:
|
||||||
os.environ.update(old_env)
|
config = AppConfig.from_dotenv("/tmp/owner-voice-pet-missing.env")
|
||||||
|
self.assertEqual(config.llm_base_url, "https://newapi.mkbk.shop")
|
||||||
|
self.assertIsNone(config.llm_api_key)
|
||||||
|
|
||||||
def test_missing_llm_key_has_structured_error(self) -> None:
|
def test_missing_llm_key_has_structured_error(self) -> None:
|
||||||
config = AppConfig(llm_api_key=None)
|
config = AppConfig(llm_api_key=None)
|
||||||
|
|||||||
Reference in New Issue
Block a user