Feature: 允许插件从环境变量中读取配置项并支持 alias (#3673)

Co-authored-by: Ju4tCode <42488585+yanyongyu@users.noreply.github.com>
This commit is contained in:
Azide
2025-10-18 19:43:01 +08:00
committed by GitHub
parent ae3cf2a72f
commit efa3ac3f4c
6 changed files with 114 additions and 40 deletions

View File

@@ -51,7 +51,7 @@ class SettingsError(ValueError): ...
class BaseSettingsSource(abc.ABC):
def __init__(self, settings_cls: type["BaseSettings"]) -> None:
def __init__(self, settings_cls: type[BaseModel]) -> None:
self.settings_cls = settings_cls
@property
@@ -67,7 +67,7 @@ class InitSettingsSource(BaseSettingsSource):
__slots__ = ("init_kwargs",)
def __init__(
self, settings_cls: type["BaseSettings"], init_kwargs: dict[str, Any]
self, settings_cls: type[BaseModel], init_kwargs: dict[str, Any]
) -> None:
self.init_kwargs = init_kwargs
super().__init__(settings_cls)
@@ -82,33 +82,17 @@ class InitSettingsSource(BaseSettingsSource):
class DotEnvSettingsSource(BaseSettingsSource):
def __init__(
self,
settings_cls: type["BaseSettings"],
env_file: Optional[DOTENV_TYPE] = ENV_FILE_SENTINEL,
env_file_encoding: Optional[str] = None,
case_sensitive: Optional[bool] = None,
settings_cls: type[BaseModel],
env_file: Optional[DOTENV_TYPE],
env_file_encoding: str,
case_sensitive: Optional[bool] = False,
env_nested_delimiter: Optional[str] = None,
) -> None:
super().__init__(settings_cls)
self.env_file = (
env_file
if env_file is not ENV_FILE_SENTINEL
else self.config.get("env_file", (".env",))
)
self.env_file_encoding = (
env_file_encoding
if env_file_encoding is not None
else self.config.get("env_file_encoding", "utf-8")
)
self.case_sensitive = (
case_sensitive
if case_sensitive is not None
else self.config.get("case_sensitive", False)
)
self.env_nested_delimiter = (
env_nested_delimiter
if env_nested_delimiter is not None
else self.config.get("env_nested_delimiter", None)
)
self.env_file = env_file
self.env_file_encoding = env_file_encoding
self.case_sensitive = case_sensitive
self.env_nested_delimiter = env_nested_delimiter
def _apply_case_sensitive(self, var_name: str) -> str:
return var_name if self.case_sensitive else var_name.lower()
@@ -212,12 +196,33 @@ class DotEnvSettingsSource(BaseSettingsSource):
for field in model_fields(self.settings_cls):
field_name = field.name
env_name = self._apply_case_sensitive(field_name)
alias_name = field.field_info.alias
alias_env_name = (
None if alias_name is None else self._apply_case_sensitive(alias_name)
)
# pydantic use alias name to validate if exist
if alias_name is not None:
field_name = alias_name
# try get values from env vars
env_val = env_vars.get(env_name, PydanticUndefined)
alias_env_val = (
PydanticUndefined
if alias_env_name is None
else env_vars.get(alias_env_name, PydanticUndefined)
)
# alias env value has higher priority
env_val = (
env_val
if isinstance(alias_env_val, PydanticUndefinedType)
else alias_env_val
)
# delete from file vars when used
if env_name in env_file_vars:
del env_file_vars[env_name]
if alias_env_name is not None and alias_env_name in env_file_vars:
del env_file_vars[alias_env_name]
is_complex, allow_parse_failure = self._field_is_complex(field)
if is_complex:
@@ -331,25 +336,48 @@ class BaseSettings(BaseModel):
_env_nested_delimiter: Optional[str] = None,
**values: Any,
) -> None:
settings_config = model_config(__settings_self__.__class__)
env_file = (
_env_file
if _env_file is not ENV_FILE_SENTINEL
else settings_config.get("env_file", (".env",))
)
env_file_encoding = (
_env_file_encoding
if _env_file_encoding is not None
else settings_config.get("env_file_encoding", "utf-8")
)
env_nested_delimiter = (
_env_nested_delimiter
if _env_nested_delimiter is not None
else settings_config.get("env_nested_delimiter", None)
)
super().__init__(
**__settings_self__._settings_build_values(
__settings_self__.__class__,
values,
env_file=_env_file,
env_file_encoding=_env_file_encoding,
env_nested_delimiter=_env_nested_delimiter,
env_file=env_file,
env_file_encoding=env_file_encoding,
env_nested_delimiter=env_nested_delimiter,
)
)
__settings_self__._env_file = env_file
__settings_self__._env_file_encoding = env_file_encoding
__settings_self__._env_nested_delimiter = env_nested_delimiter
@staticmethod
def _settings_build_values(
self,
settings_cls: type[BaseModel],
init_kwargs: dict[str, Any],
env_file: Optional[DOTENV_TYPE] = None,
env_file_encoding: Optional[str] = None,
env_nested_delimiter: Optional[str] = None,
env_file: Optional[DOTENV_TYPE],
env_file_encoding: str,
env_nested_delimiter: Optional[str],
) -> dict[str, Any]:
init_settings = InitSettingsSource(self.__class__, init_kwargs=init_kwargs)
init_settings = InitSettingsSource(settings_cls, init_kwargs=init_kwargs)
env_settings = DotEnvSettingsSource(
self.__class__,
settings_cls,
env_file=env_file,
env_file_encoding=env_file_encoding,
env_nested_delimiter=env_nested_delimiter,

View File

@@ -47,6 +47,7 @@ from pydantic import BaseModel
from nonebot import get_driver
from nonebot.compat import model_dump, type_validate_python
from nonebot.config import BaseSettings
C = TypeVar("C", bound=BaseModel)
@@ -172,7 +173,17 @@ def get_available_plugin_names() -> set[str]:
def get_plugin_config(config: type[C]) -> C:
"""从全局配置获取当前插件需要的配置项。"""
return type_validate_python(config, model_dump(get_driver().config))
global_config = get_driver().config
return type_validate_python(
config,
BaseSettings._settings_build_values(
config,
model_dump(global_config),
env_file=global_config._env_file,
env_file_encoding=global_config._env_file_encoding,
env_nested_delimiter=global_config._env_nested_delimiter,
),
)
from .load import inherit_supported_adapters as inherit_supported_adapters

View File

@@ -10,6 +10,7 @@ NESTED__C__C=3
NESTED__COMPLEX=[1, 2, 3]
NESTED_INNER__A=1
NESTED_INNER__B=2
ALIAS_SIMPLE=aliased_simple
OTHER_SIMPLE=simple
OTHER_NESTED={"a": 1}
OTHER_NESTED__B=2

View File

@@ -37,6 +37,7 @@ class Example(BaseSettings):
complex_union: Union[int, list[int]] = 1
nested: Simple = Simple()
nested_inner: Simple = Simple()
aliased_simple: str = Field(default="", alias="alias_simple")
class ExampleWithoutDelimiter(Example):
@@ -85,6 +86,8 @@ def test_config_with_env():
with pytest.raises(AttributeError):
config.nested_inner__b
assert config.aliased_simple == "aliased_simple"
assert config.common_config == "common"
assert config.other_simple == "simple"

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
import pytest
import nonebot
from nonebot.plugin import PluginManager, _managers
@@ -67,3 +68,27 @@ def test_get_plugin_config():
config = nonebot.get_plugin_config(Config)
assert isinstance(config, Config)
assert config.plugin_config == 1
def test_get_plugin_config_with_env(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("PLUGIN_CONFIG_ONE", "no_dummy_val")
monkeypatch.setenv("PLUGIN_SUB_CONFIG__TWO", "two")
monkeypatch.setenv("PLUGIN_CFG_THREE", "33")
monkeypatch.setenv("CONFIG_FROM_INIT", "impossible")
class SubConfig(BaseModel):
two: str = "dummy_val"
class Config(BaseModel):
plugin_config: int
plugin_config_one: str = "dummy_val"
plugin_sub_config: SubConfig = Field(default_factory=SubConfig)
plugin_config_three: int = Field(default=3, alias="plugin_cfg_three")
config_from_init: str = "dummy_val"
config = nonebot.get_plugin_config(Config)
assert config.plugin_config == 1
assert config.plugin_config_one == "no_dummy_val"
assert config.plugin_sub_config.two == "two"
assert config.plugin_config_three == 33
assert config.config_from_init == "init"

View File

@@ -84,7 +84,7 @@ export CUSTOM_CONFIG='config in environment variables'
那最终 NoneBot 所读取的内容为环境变量中的内容,即 `config in environment variables`。
:::caution 注意
NoneBot 不会自发读取未被定义的配置项的环境变量,如果需要读取某一环境变量需要在 dotenv 配置文件中行声明。
如果一个环境变量既不是 NoneBot 的[**内置配置项**](#内置配置项),也不是任何插件所定义的[**插件配置**](#插件配置),那么 NoneBot 不会自发读取该环境变量需要在 dotenv 配置文件中行声明。
:::
### dotenv 配置文件
@@ -242,11 +242,17 @@ weather = on_command(
这种方式可以简洁、高效地读取配置项,同时也可以设置默认值或者在运行时对配置项进行合法性检查,防止由于配置项导致的插件出错等情况出现。
:::tip 提示
:::tip 可配置的事件响应优先级
发布插件应该为自身的事件响应器提供可配置的优先级,以便插件使用者可以自定义多个插件间的响应顺序。
:::
由于插件配置项是从全局配置中读取的,通常我们需要在配置项名称前面添加前缀名,以防止配置项冲突。例如在上方的示例中,我们就添加了配置项前缀 `weather_`。但是这样会导致在使用配置项时过长的变量名,因此我们可以使用 `pydantic` 的 `alias` 或者通过配置 scope 来简化配置项名称。这里我们以 scope 配置为例:
:::tip 插件配置获取逻辑
无论是否在 dotenv 文件中声明了插件配置项,使用 `get_plugin_config` 获取插件配置模型中定义的配置项时都遵循[**配置项的加载**](#配置项的加载)一节中的优先级顺序进行读取。
:::
### 避免插件配置名称冲突
由于插件配置项是从全局配置和环境变量中读取的,通常我们需要在配置项名称前面添加前缀名,以防止配置项冲突。例如在上方的示例中,我们就添加了配置项前缀 `weather_`。但是这样会导致使用配置项时变量名过长,此时我们可以使用 `pydantic` 的 `alias` 或者通过配置 scope 来简化配置项名称。这里我们以 scope 配置为例:
```python title=weather/config.py
from pydantic import BaseModel