diff --git a/README.md b/README.md index 2264def..29cbb5b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## 配置 -密钥不得写入仓库。运行时通过环境变量配置: +密钥不得写入仓库。运行时默认读取项目根目录的 `.env` 文件: - `OWNER_LLM_BASE_URL` - `OWNER_LLM_API_KEY` @@ -25,6 +25,8 @@ 默认配置面向当前 NewAPI: ```bash +cp .env.example .env +# 然后编辑 .env,填入 OWNER_LLM_API_KEY OWNER_LLM_BASE_URL=https://newapi.mkbk.shop OWNER_LLM_MODEL=gpt-5.4-mini OWNER_LLM_API_STYLE=chat_completions @@ -44,7 +46,6 @@ openspec validate --all --strict 真实 NewAPI smoke 测试示例: ```bash -OWNER_LLM_API_KEY=你的临时Key \ PYTHONPATH=src python3.11 -m owner_voice_pet llm-smoke --no-stream --message '用一句中文回复:小杰在线。' ``` diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index 4869b7f..6a0be62 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -21,6 +21,7 @@ from .wakeword import KeywordWakeWordProvider def main(argv: list[str] | None = None) -> int: 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") subparsers = parser.add_subparsers(dest="command") 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) if args.show_config: - config = AppConfig.from_env() + config = AppConfig.from_dotenv(args.env_file) print( json.dumps( { @@ -53,7 +54,7 @@ def main(argv: list[str] | None = None) -> int: return 0 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)) return 0 @@ -68,7 +69,7 @@ def main(argv: list[str] | None = None) -> int: return 0 if result["success"] else 1 if args.command == "llm-smoke": - config = AppConfig.from_env() + config = AppConfig.from_dotenv(args.env_file) if args.no_stream: config = AppConfig( wake_word=config.wake_word, @@ -102,7 +103,7 @@ def main(argv: list[str] | None = None) -> int: def run_acceptance() -> dict[str, object]: - validate_pet_assets(AppConfig.from_env().asset_dir) + validate_pet_assets(AppConfig.from_dotenv().asset_dir) frames = [ 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": "你是谁"}), diff --git a/src/owner_voice_pet/config.py b/src/owner_voice_pet/config.py index 5f9fdcd..884270e 100644 --- a/src/owner_voice_pet/config.py +++ b/src/owner_voice_pet/config.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from dataclasses import dataclass from pathlib import Path @@ -25,9 +24,11 @@ class AppConfig: context_max_chars: int = 12000 @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: - value = os.environ.get(f"{prefix}{name}") + value = values.get(f"{prefix}{name}") return default if value is None or value == "" else value return cls( @@ -47,6 +48,10 @@ class AppConfig: 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: if not self.llm_api_key: raise ProviderError( @@ -100,3 +105,32 @@ class AppConfig: ) ) 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 diff --git a/tests/test_cli_acceptance.py b/tests/test_cli_acceptance.py index 4a9592b..90438e1 100644 --- a/tests/test_cli_acceptance.py +++ b/tests/test_cli_acceptance.py @@ -27,6 +27,19 @@ class CliAcceptanceTests(unittest.TestCase): self.assertEqual(code, 0) 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: code, data = self.call("security-check") self.assertEqual(code, 0) diff --git a/tests/test_models_config.py b/tests/test_models_config.py index e49f79f..97627ad 100644 --- a/tests/test_models_config.py +++ b/tests/test_models_config.py @@ -1,6 +1,6 @@ from __future__ import annotations -import os +import tempfile import unittest 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/openai-compatible", str(error)) - def test_config_from_env_masks_secret_boundary(self) -> None: - old_env = dict(os.environ) - try: - os.environ["OWNER_LLM_BASE_URL"] = "https://newapi.mkbk.shop/" - os.environ["OWNER_LLM_API_KEY"] = "secret-value" - os.environ["OWNER_LLM_MODEL"] = "test-model" - config = AppConfig.from_env() + def test_config_from_dotenv_uses_file_values(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = f"{tmp}/.env" + with open(path, "w", encoding="utf-8") as handle: + handle.write( + "\n".join( + [ + "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_api_key, "secret-value") self.assertEqual(config.llm_model, "test-model") self.assertTrue(config.llm_stream) self.assertEqual(config.validate_basic(), []) - finally: - os.environ.clear() - os.environ.update(old_env) + + def test_missing_dotenv_uses_non_secret_defaults(self) -> None: + 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: config = AppConfig(llm_api_key=None)