|
@@ -6,18 +6,30 @@
|
|
|
必须等于「什么都不配也能跑起来」的那套(沿用 1.5 的工程纪律:默认 = 可用的
|
|
必须等于「什么都不配也能跑起来」的那套(沿用 1.5 的工程纪律:默认 = 可用的
|
|
|
最小配置,任何新开关的默认值都不应改变既有行为)。
|
|
最小配置,任何新开关的默认值都不应改变既有行为)。
|
|
|
|
|
|
|
|
-## 关于环境变量污染(踩过的坑,别改回去)
|
|
|
|
|
-
|
|
|
|
|
-机器上若存在全局的 ``OPENAI_API_KEY``,``load_dotenv(override=False)`` 会让
|
|
|
|
|
-**进程环境胜出、项目 .env 被静默忽略**。表现是:配好的密钥明明是对的,服务
|
|
|
|
|
-却报 401,且报错里显示的密钥后几位和 .env 里的对不上——足以浪费半天。
|
|
|
|
|
-
|
|
|
|
|
-所以这里的策略是:
|
|
|
|
|
-1. **项目 .env 优先**(``override=True``)。.env 是本项目对外声明的配置面
|
|
|
|
|
- (有 .env.example 为证),比机器上某个无关的全局变量更具体。
|
|
|
|
|
-2. 若没有 .env 文件,行为与常规一致:纯用进程环境变量(容器/CI 场景不受影响)。
|
|
|
|
|
-3. 两者**都存在且值不同**时,记一条 WARNING 并把冲突键名暴露给 /health,
|
|
|
|
|
- 让「我改了配置为什么不生效」有迹可循。
|
|
|
|
|
|
|
+## 关于环境变量与 .env 的优先级(踩过的坑,别改回去)
|
|
|
|
|
+
|
|
|
|
|
+机器上若存在全局的 ``OPENAI_API_KEY``(实测遇到过一把无效的),进程环境会
|
|
|
|
|
+**静默盖掉项目 .env**。症状是:配好的密钥明明是对的,服务却报 401,且报错
|
|
|
|
|
+里显示的密钥后几位和 .env 里的对不上——足以浪费半天。
|
|
|
|
|
+
|
|
|
|
|
+所以优先级做成**显式可配**,而不是隐含假定某一方赢:
|
|
|
|
|
+
|
|
|
|
|
+=================== ====================================================
|
|
|
|
|
+``DEEP_AGENT_ENV_PRIORITY`` 行为
|
|
|
|
|
+=================== ====================================================
|
|
|
|
|
+``dotenv``(默认) 项目 .env 优先。防「被污染的 shell 静默改掉项目配置」
|
|
|
|
|
+``process`` 进程环境优先。用于**跑变体实例**:不同端口、不同库、
|
|
|
|
|
+ 不同方言(``DB_DIALECT=mysql PORT=8081 python main.py``)
|
|
|
|
|
+``none`` 完全忽略 .env,只认进程环境与代码默认值(测试/CI 用)
|
|
|
|
|
+=================== ====================================================
|
|
|
|
|
+
|
|
|
|
|
+无论哪一档,**两侧值不同都会打 WARNING 并把键名暴露给 /health**,
|
|
|
|
|
+让「我改了配置为什么不生效」有迹可循。
|
|
|
|
|
+
|
|
|
|
|
+为什么默认不是 ``process``:两种失败模式的代价不对等。``dotenv`` 的代价是
|
|
|
|
|
+「有意的环境变量覆盖不生效」(一条命令行加个前缀就绕过了,且启动日志会告诉
|
|
|
|
|
+你),``process`` 的代价是「本机某个无关的全局变量静默改掉项目配置」(表现为
|
|
|
|
|
+身份诡异的 401 或连错数据库)。
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
@@ -32,14 +44,14 @@ from dotenv import dotenv_values, load_dotenv
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
|
|
|
|
_ENV_PATH = ROOT / ".env"
|
|
_ENV_PATH = ROOT / ".env"
|
|
|
-# 测试/CI 用:设 DEEP_AGENT_SKIP_DOTENV=1 可完全跳过 .env,只认进程环境变量
|
|
|
|
|
-# 与代码默认值。没有这个开关,测试就会受开发者本机 .env 的内容影响——
|
|
|
|
|
-# 同一套测试在不同机器上跑出不同结果,是最难查的一类失败。
|
|
|
|
|
-_SKIP_DOTENV = os.getenv("DEEP_AGENT_SKIP_DOTENV", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
+# dotenv(默认)| process | none
|
|
|
|
|
+_ENV_PRIORITY = (os.getenv("DEEP_AGENT_ENV_PRIORITY", "dotenv") or "dotenv").strip().lower()
|
|
|
|
|
+if _ENV_PRIORITY not in {"dotenv", "process", "none"}:
|
|
|
|
|
+ _ENV_PRIORITY = "dotenv"
|
|
|
|
|
|
|
|
# 直接读文件(不经 os.environ),用于检测冲突
|
|
# 直接读文件(不经 os.environ),用于检测冲突
|
|
|
_FILE_VALUES: dict[str, str] = (
|
|
_FILE_VALUES: dict[str, str] = (
|
|
|
- {} if _SKIP_DOTENV else {k: v for k, v in dotenv_values(_ENV_PATH).items() if v}
|
|
|
|
|
|
|
+ {} if _ENV_PRIORITY == "none" else {k: v for k, v in dotenv_values(_ENV_PATH).items() if v}
|
|
|
)
|
|
)
|
|
|
# 冲突记录:键名 → 说明。供 log_config_sources() 与 /health 使用
|
|
# 冲突记录:键名 → 说明。供 log_config_sources() 与 /health 使用
|
|
|
CONFLICTS: dict[str, str] = {}
|
|
CONFLICTS: dict[str, str] = {}
|
|
@@ -47,11 +59,15 @@ CONFLICTS: dict[str, str] = {}
|
|
|
for _key, _file_value in _FILE_VALUES.items():
|
|
for _key, _file_value in _FILE_VALUES.items():
|
|
|
_process_value = os.environ.get(_key)
|
|
_process_value = os.environ.get(_key)
|
|
|
if _process_value and _process_value != _file_value:
|
|
if _process_value and _process_value != _file_value:
|
|
|
- CONFLICTS[_key] = "项目 .env 覆盖了同名进程环境变量"
|
|
|
|
|
|
|
+ CONFLICTS[_key] = (
|
|
|
|
|
+ "项目 .env 覆盖了同名进程环境变量"
|
|
|
|
|
+ if _ENV_PRIORITY == "dotenv"
|
|
|
|
|
+ else "进程环境变量覆盖了项目 .env"
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
-# .env 优先:见模块 docstring 的「环境变量污染」一节
|
|
|
|
|
-if not _SKIP_DOTENV:
|
|
|
|
|
- load_dotenv(_ENV_PATH, override=True)
|
|
|
|
|
|
|
+# 见模块 docstring 的「环境变量与 .env 的优先级」一节
|
|
|
|
|
+if _ENV_PRIORITY != "none":
|
|
|
|
|
+ load_dotenv(_ENV_PATH, override=(_ENV_PRIORITY == "dotenv"))
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mask(value: str) -> str:
|
|
def _mask(value: str) -> str:
|
|
@@ -68,17 +84,30 @@ def log_config_sources() -> None:
|
|
|
from obs import get_logger
|
|
from obs import get_logger
|
|
|
|
|
|
|
|
logger = get_logger("config")
|
|
logger = get_logger("config")
|
|
|
- if _ENV_PATH.exists():
|
|
|
|
|
- logger.info("配置来源:%s(%d 项,其中 %d 项覆盖了同名进程环境变量)",
|
|
|
|
|
- _ENV_PATH, len(_FILE_VALUES), len(CONFLICTS))
|
|
|
|
|
|
|
+ if _ENV_PRIORITY == "none":
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "DEEP_AGENT_ENV_PRIORITY=none:已忽略 %s,全部配置取自进程环境变量与代码默认值",
|
|
|
|
|
+ _ENV_PATH,
|
|
|
|
|
+ )
|
|
|
|
|
+ elif _ENV_PATH.exists():
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ "配置来源:%s(%d 项)| 优先级=%s | 冲突 %d 项",
|
|
|
|
|
+ _ENV_PATH, len(_FILE_VALUES), _ENV_PRIORITY, len(CONFLICTS),
|
|
|
|
|
+ )
|
|
|
else:
|
|
else:
|
|
|
logger.warning("未找到 %s,全部配置取自进程环境变量与代码默认值", _ENV_PATH)
|
|
logger.warning("未找到 %s,全部配置取自进程环境变量与代码默认值", _ENV_PATH)
|
|
|
|
|
|
|
|
for key, reason in CONFLICTS.items():
|
|
for key, reason in CONFLICTS.items():
|
|
|
# 只暴露键名与脱敏后的值,不打印完整密钥
|
|
# 只暴露键名与脱敏后的值,不打印完整密钥
|
|
|
|
|
+ effective = os.getenv(key, "")
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
- "配置冲突 %s:%s(生效值 %s)",
|
|
|
|
|
- key, reason, _mask(_FILE_VALUES.get(key, "")),
|
|
|
|
|
|
|
+ "配置冲突 %s:%s(生效值 %s)%s",
|
|
|
|
|
+ key,
|
|
|
|
|
+ reason,
|
|
|
|
|
+ _mask(effective),
|
|
|
|
|
+ ";如需让进程环境优先,加 DEEP_AGENT_ENV_PRIORITY=process"
|
|
|
|
|
+ if _ENV_PRIORITY == "dotenv"
|
|
|
|
|
+ else "",
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|